feat: improve growth analysis waiting experience

This commit is contained in:
2026-08-21 02:57:46 +03:30
parent 9f575ec151
commit 71ea59248c
11 changed files with 320 additions and 16 deletions

View File

@@ -13,7 +13,7 @@
- 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. - 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. - 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. - 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. - `PARTIAL` keeps deterministic charts/cards and explains that the written AI interpretation was unavailable. `LOW` keeps the forecast visible with an explicit limited-history or high-variance warning. `INSUFFICIENT_DATA` is reserved for scopes with no historical observations. `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. - Motion is restrained, supports reduced motion, and does not block reading. All interactive targets are at least 44 px.
## Copy rules ## Copy rules
@@ -28,4 +28,4 @@
- Works at 360, 768, 1024, and wide desktop widths in light/dark RTL layouts. - 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. - 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. - 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. - Cached navigation performs no generation request. Manual retry remains available while the current scope has no persisted AI-written summary; after one exists, refresh is limited once per merchant per Tehran day.

View File

@@ -337,7 +337,7 @@ export interface components {
schemas: { schemas: {
CreateGrowthReportRequest: { CreateGrowthReportRequest: {
terminalId?: string; terminalId?: string;
/** @description When omitted, the request reuses the current matching report. */ /** @description When omitted, the request reuses the current matching report. Manual retries remain available until the requested scope has persisted AI copy. */
refresh?: boolean; refresh?: boolean;
}; };
GrowthRange: { GrowthRange: {
@@ -365,7 +365,7 @@ export interface components {
/** @enum {integer} */ /** @enum {integer} */
horizonDays: 90; horizonDays: 90;
/** @enum {string} */ /** @enum {string} */
confidence: 'HIGH' | 'MEDIUM' | 'INSUFFICIENT_DATA'; confidence: 'HIGH' | 'MEDIUM' | 'LOW' | 'INSUFFICIENT_DATA';
suppressionReason?: string; suppressionReason?: string;
points: components['schemas']['GrowthForecastPoint'][]; points: components['schemas']['GrowthForecastPoint'][];
backtest: { backtest: {
@@ -498,7 +498,10 @@ export interface components {
failureCode?: string; failureCode?: string;
cached: boolean; cached: boolean;
stale: boolean; stale: boolean;
/** Format: date-time */ /**
* Format: date-time
* @description Current time while no AI summary exists for the scope; otherwise the next refresh boundary for that terminal scope in the Tehran day. The all-terminals selection is a separate scope.
*/
nextRefreshAt: string; nextRefreshAt: string;
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;

View File

@@ -0,0 +1,223 @@
<script lang="ts">
type WorkingStatus = 'QUEUED' | 'ANALYZING' | 'GENERATING';
let { status = 'QUEUED', overlay = false }: { status?: WorkingStatus; overlay?: boolean } = $props();
const title = $derived(
overlay && status === 'QUEUED'
? 'نسخه تازه تحلیل در حال آماده‌سازی است'
: status === 'GENERATING'
? 'در حال نوشتن جمع‌بندی تحلیلی'
: status === 'ANALYZING'
? 'در حال بررسی روندهای کسب‌وکار شما'
: 'تحلیل شما در صف آماده‌سازی است',
);
</script>
<section
class={overlay
? 'bg-background/90 pointer-events-none absolute inset-0 z-20 flex min-h-full flex-col items-center justify-center rounded-lg px-4 py-8 text-center backdrop-blur-[2px] sm:px-8'
: 'border-border bg-card flex min-h-96 flex-col items-center justify-center rounded-lg border px-4 py-10 text-center sm:px-8'}
role="status"
aria-label={overlay
? 'هوش مصنوعی در حال به‌روزرسانی تحلیل رشد است'
: 'هوش مصنوعی در حال ساخت تحلیل رشد است'}
aria-live="polite"
aria-busy="true"
>
<svg
class={overlay ? 'working-illustration w-full max-w-60' : 'working-illustration w-full max-w-80'}
viewBox="0 0 320 220"
role="img"
aria-hidden="true"
focusable="false"
>
<rect class="document" x="77" y="28" width="166" height="164" rx="8" />
<path class="document-fold" d="M211 28v32h32" />
<g class="chart" transform="translate(101 104)">
<rect x="0" y="30" width="18" height="28" rx="3" />
<rect x="29" y="17" width="18" height="41" rx="3" />
<rect x="58" y="4" width="18" height="54" rx="3" />
<path d="M2 17 36 5l31-18 31 5" />
<circle cx="2" cy="17" r="4" />
<circle cx="36" cy="5" r="4" />
<circle cx="67" cy="-13" r="4" />
<circle cx="98" cy="-8" r="4" />
</g>
<g class="ai-core" transform="translate(160 75)">
<circle class="core-halo" r="31" />
<circle class="core" r="21" />
<path d="M-9-2h18M-7 7h14M0-12v24" />
</g>
<g class="data-points">
<circle cx="49" cy="72" r="5" />
<circle cx="270" cy="93" r="5" />
<circle cx="55" cy="161" r="5" />
<path d="M55 74 126 75M265 95l-73-18M60 158l49-23" />
</g>
<g class="writing-lines">
<path d="M104 174h112" />
<path d="M104 184h72" />
</g>
</svg>
<h2 class="mt-2 text-base font-bold sm:text-lg">{title}</h2>
<p class="text-muted-foreground mt-2 max-w-xl text-sm leading-7">
هوش مصنوعی داده‌ها را کنار هم می‌گذارد، الگوها را بررسی می‌کند و خلاصه‌ای کاربردی از وضعیت رشد شما
می‌سازد.
</p>
<div class="mt-5 flex items-center gap-2" aria-hidden="true">
<span class="progress-dot"></span>
<span class="progress-dot"></span>
<span class="progress-dot"></span>
</div>
</section>
<style>
.working-illustration {
color: var(--primary);
}
.document {
fill: var(--card);
stroke: var(--border);
stroke-width: 2;
}
.document-fold,
.writing-lines path {
fill: none;
stroke: var(--border);
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.chart rect {
fill: color-mix(in srgb, var(--primary) 18%, transparent);
}
.chart path {
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 3;
}
.chart circle,
.data-points circle {
fill: var(--primary);
}
.chart {
animation: chart-build 1.8s cubic-bezier(0.77, 0, 0.175, 1) infinite alternate;
transform-box: fill-box;
transform-origin: center bottom;
}
.core-halo {
fill: color-mix(in srgb, var(--primary) 12%, transparent);
animation: halo-pulse 1.8s cubic-bezier(0.77, 0, 0.175, 1) infinite alternate;
transform-box: fill-box;
transform-origin: center;
}
.core {
fill: var(--primary);
}
.ai-core path {
fill: none;
stroke: var(--primary-foreground);
stroke-linecap: round;
stroke-width: 2.5;
}
.data-points path {
fill: none;
stroke: color-mix(in srgb, var(--primary) 45%, var(--border));
stroke-dasharray: 5 6;
stroke-linecap: round;
stroke-width: 2;
}
.data-points {
animation: data-gather 1.8s cubic-bezier(0.77, 0, 0.175, 1) infinite alternate;
transform-box: fill-box;
transform-origin: center;
}
.progress-dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 9999px;
background: var(--primary);
animation: dot-pulse 900ms cubic-bezier(0.77, 0, 0.175, 1) infinite alternate;
}
.progress-dot:nth-child(2) {
animation-delay: 150ms;
}
.progress-dot:nth-child(3) {
animation-delay: 300ms;
}
@keyframes chart-build {
from {
opacity: 0.55;
transform: translate(101px, 104px) scaleY(0.92);
}
to {
opacity: 1;
transform: translate(101px, 104px) scaleY(1);
}
}
@keyframes halo-pulse {
from {
opacity: 0.4;
transform: scale(0.92);
}
to {
opacity: 1;
transform: scale(1.06);
}
}
@keyframes data-gather {
from {
opacity: 0.45;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dot-pulse {
from {
opacity: 0.3;
transform: translateY(0);
}
to {
opacity: 1;
transform: translateY(-35%);
}
}
@media (prefers-reduced-motion: reduce) {
.chart,
.core-halo,
.data-points,
.progress-dot {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import GrowthAIWorkingIllustration from './GrowthAIWorkingIllustration.svelte';
describe('GrowthAIWorkingIllustration', () => {
it('announces that the AI is assembling the initial growth analysis', () => {
const { container } = render(GrowthAIWorkingIllustration, { status: 'ANALYZING' });
expect(
screen.getByRole('status', { name: 'هوش مصنوعی در حال ساخت تحلیل رشد است' }).getAttribute('aria-busy'),
).toBe('true');
expect(screen.getByText('در حال بررسی روندهای کسب‌وکار شما')).not.toBeNull();
expect(screen.getByText(/داده‌ها را کنار هم می‌گذارد/)).not.toBeNull();
expect(container.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true');
});
it('describes the writing phase when the model is generating the summary', () => {
render(GrowthAIWorkingIllustration, { status: 'GENERATING' });
expect(screen.getByText('در حال نوشتن جمع‌بندی تحلیلی')).not.toBeNull();
});
it('announces refresh work while covering an existing report', () => {
render(GrowthAIWorkingIllustration, { status: 'QUEUED', overlay: true });
expect(
screen.getByRole('status', { name: 'هوش مصنوعی در حال به‌روزرسانی تحلیل رشد است' }),
).not.toBeNull();
expect(screen.getByText('نسخه تازه تحلیل در حال آماده‌سازی است')).not.toBeNull();
});
});

View File

@@ -214,6 +214,15 @@
<p class="text-muted-foreground px-3 pb-4 text-xs"> <p class="text-muted-foreground px-3 pb-4 text-xs">
شروع پیش‌بینی: {dateLabel(startDate)}. این برآورد قطعی نیست و بر پایه الگوی داده‌های کامل واردشده است. شروع پیش‌بینی: {dateLabel(startDate)}. این برآورد قطعی نیست و بر پایه الگوی داده‌های کامل واردشده است.
</p> </p>
{#if confidence === 'LOW'}
<p
class="mx-3 mb-4 rounded-md border border-amber-300/60 bg-amber-50/50 p-3 text-xs leading-6 dark:border-amber-800/70 dark:bg-amber-950/15"
role="status"
>
این پیش‌بینی با سابقه محدود یا الگوی پرنوسان ساخته شده است؛ بازه احتمالی را مبنای تصمیم بگیرید، نه
عدد میانی را به‌عنوان نتیجه قطعی.
</p>
{/if}
<details class="border-t px-3 py-3 text-xs"> <details class="border-t px-3 py-3 text-xs">
<summary class="font-semibold">جدول داده‌های نمودار</summary> <summary class="font-semibold">جدول داده‌های نمودار</summary>
<div class="mt-3 max-h-72 overflow-auto"> <div class="mt-3 max-h-72 overflow-auto">

View File

@@ -32,4 +32,14 @@ describe('GrowthForecastChart', () => {
await fireEvent.click(screen.getByRole('button', { name: 'مبلغ' })); await fireEvent.click(screen.getByRole('button', { name: 'مبلغ' }));
expect(screen.getAllByText(/ریال/).length).toBeGreaterThan(0); expect(screen.getAllByText(/ریال/).length).toBeGreaterThan(0);
}); });
it('explains that a low-confidence forecast uses limited history', () => {
render(GrowthForecastChart, {
points,
confidence: 'LOW',
startDate: '2026-07-01T00:00:00Z',
});
expect(screen.getByText(/سابقه محدود/)).not.toBeNull();
});
}); });

View File

@@ -10,11 +10,11 @@
import { Card, CardContent } from '$lib/components/ui/card'; import { Card, CardContent } from '$lib/components/ui/card';
import { useDashboardContext } from '$lib/dashboard-context.svelte'; import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { growthFeatureEnabled } from '$lib/growth-feature'; import { growthFeatureEnabled } from '$lib/growth-feature';
import { reportDisplayMode } from '$lib/growth-report-state'; import { refreshAvailabilityLabel, reportDisplayMode } from '$lib/growth-report-state';
import { formatDate } from '$lib/format'; import { formatDate } from '$lib/format';
import GrowthAIWorkingIllustration from './GrowthAIWorkingIllustration.svelte';
import GrowthForecastChart from './GrowthForecastChart.svelte'; import GrowthForecastChart from './GrowthForecastChart.svelte';
import GrowthInsights from './GrowthInsights.svelte'; import GrowthInsights from './GrowthInsights.svelte';
import GrowthReportSkeleton from './GrowthReportSkeleton.svelte';
import PageHeader from './PageHeader.svelte'; import PageHeader from './PageHeader.svelte';
const dashboard = useDashboardContext(); const dashboard = useDashboardContext();
@@ -201,7 +201,9 @@
></Card ></Card
> >
{:else if (loading && !visibleReport) || displayMode === 'loading'} {:else if (loading && !visibleReport) || displayMode === 'loading'}
<GrowthReportSkeleton /> <GrowthAIWorkingIllustration
status={report?.status === 'ANALYZING' || report?.status === 'GENERATING' ? report.status : 'QUEUED'}
/>
{:else if displayMode === 'error' || !report || !visibleReport || !evidence} {:else if displayMode === 'error' || !report || !visibleReport || !evidence}
<Card class="border-destructive/30 rounded-lg" <Card class="border-destructive/30 rounded-lg"
><CardContent class="space-y-4 p-10 text-center" ><CardContent class="space-y-4 p-10 text-center"
@@ -226,7 +228,12 @@
<Button class="min-h-11 rounded-md" onclick={refresh} disabled={refreshing || isRunning} <Button class="min-h-11 rounded-md" onclick={refresh} disabled={refreshing || isRunning}
><RefreshCcwIcon class={refreshing ? 'animate-spin' : ''} /> به‌روزرسانی تحلیل</Button ><RefreshCcwIcon class={refreshing ? 'animate-spin' : ''} /> به‌روزرسانی تحلیل</Button
> >
<span class="text-muted-foreground text-[11px]">نوبت بعدی: {formatDate(report.nextRefreshAt)}</span> <span class="text-muted-foreground text-[11px]">
{refreshAvailabilityLabel(
visibleReport.status === 'READY' && Boolean(visibleReport.aiCopy?.briefs.length),
formatDate(report.nextRefreshAt),
)}
</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -252,13 +259,12 @@
</div> </div>
{/if} {/if}
{#if evidence.forecast.confidence === 'INSUFFICIENT_DATA'} {#if evidence.forecast.points.length === 0}
<Card class="rounded-lg" <Card class="rounded-lg"
><CardContent class="p-10 text-center" ><CardContent class="p-10 text-center"
><h2 class="font-bold">برای پیش‌بینی قابل اتکا هنوز داده کافی نداریم</h2> ><h2 class="font-bold">هنوز داده‌ای برای ساخت پیش‌بینی وجود ندارد</h2>
<p class="text-muted-foreground mx-auto mt-2 max-w-xl text-sm leading-7"> <p class="text-muted-foreground mx-auto mt-2 max-w-xl text-sm leading-7">
این بخش فقط زمانی پیش‌بینی نشان می‌دهد که سابقه کافی برای مقایسه روزهای مشابه و ارزیابی دقت مدل پس از ثبت نخستین داده کامل، مدل پیش‌بینی را با درجه اطمینان متناسب نمایش می‌دهد.
وجود داشته باشد.
</p></CardContent </p></CardContent
></Card ></Card
> >
@@ -279,6 +285,11 @@
/> />
{/if} {/if}
{#if refreshing || isRunning}<GrowthReportSkeleton refreshing />{/if} {#if refreshing || isRunning}
<GrowthAIWorkingIllustration
overlay
status={report.status === 'ANALYZING' || report.status === 'GENERATING' ? report.status : 'QUEUED'}
/>
{/if}
</div> </div>
{/if} {/if}

View File

@@ -29,6 +29,6 @@ describe('growth forecast chart presentation', () => {
it('translates forecast confidence without implying certainty', () => { it('translates forecast confidence without implying certainty', () => {
expect(formatConfidence('HIGH')).toBe('اطمینان بالا'); expect(formatConfidence('HIGH')).toBe('اطمینان بالا');
expect(formatConfidence('MEDIUM')).toBe('اطمینان متوسط'); expect(formatConfidence('MEDIUM')).toBe('اطمینان متوسط');
expect(formatConfidence('INSUFFICIENT')).toBe('داده ناکافی'); expect(formatConfidence('LOW')).toBe('اطمینان پایین');
}); });
}); });

View File

@@ -43,5 +43,6 @@ export function buildForecastChart(data: ForecastChartDatum[], width: number, he
export function formatConfidence(value: string): string { export function formatConfidence(value: string): string {
if (value === 'HIGH') return 'اطمینان بالا'; if (value === 'HIGH') return 'اطمینان بالا';
if (value === 'MEDIUM') return 'اطمینان متوسط'; if (value === 'MEDIUM') return 'اطمینان متوسط';
if (value === 'LOW') return 'اطمینان پایین';
return 'داده ناکافی'; return 'داده ناکافی';
} }

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { reportDisplayMode } from './growth-report-state'; import { refreshAvailabilityLabel, reportDisplayMode } from './growth-report-state';
describe('growth report display state', () => { describe('growth report display state', () => {
it('keeps an uncached queued report in a visible loading state', () => { it('keeps an uncached queued report in a visible loading state', () => {
@@ -19,3 +19,12 @@ describe('growth report display state', () => {
expect(reportDisplayMode('FAILED', false, false)).toBe('error'); expect(reportDisplayMode('FAILED', false, false)).toBe('error');
}); });
}); });
describe('growth report refresh availability', () => {
it('keeps retries visibly available until an AI summary exists', () => {
expect(refreshAvailabilityLabel(false, '۱ شهریور')).toBe(
'تا آماده‌شدن توضیح هوشمند، تلاش دوباره محدود نیست.',
);
expect(refreshAvailabilityLabel(true, '۱ شهریور')).toBe('نوبت بعدی: ۱ شهریور');
});
});

View File

@@ -9,3 +9,9 @@ export function reportDisplayMode(
if (status === 'QUEUED' || status === 'ANALYZING' || status === 'GENERATING') return 'loading'; if (status === 'QUEUED' || status === 'ANALYZING' || status === 'GENERATING') return 'loading';
return 'error'; return 'error';
} }
export function refreshAvailabilityLabel(hasAISummary: boolean, nextRefreshLabel: string): string {
return hasAISummary
? `نوبت بعدی: ${nextRefreshLabel}`
: 'تا آماده‌شدن توضیح هوشمند، تلاش دوباره محدود نیست.';
}