From 71ea59248c9bb67a188f3f531a490c771936f416 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 21 Aug 2026 02:57:46 +0330 Subject: [PATCH] feat: improve growth analysis waiting experience --- docs/GROWTH-ANALYST-UX-SPEC.md | 4 +- src/lib/api/schema.d.ts | 9 +- .../GrowthAIWorkingIllustration.svelte | 223 ++++++++++++++++++ .../GrowthAIWorkingIllustration.test.ts | 32 +++ src/lib/components/GrowthForecastChart.svelte | 9 + .../components/GrowthForecastChart.test.ts | 10 + src/lib/components/GrowthPage.svelte | 29 ++- src/lib/growth-chart.test.ts | 2 +- src/lib/growth-chart.ts | 1 + src/lib/growth-report-state.test.ts | 11 +- src/lib/growth-report-state.ts | 6 + 11 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 src/lib/components/GrowthAIWorkingIllustration.svelte create mode 100644 src/lib/components/GrowthAIWorkingIllustration.test.ts diff --git a/docs/GROWTH-ANALYST-UX-SPEC.md b/docs/GROWTH-ANALYST-UX-SPEC.md index 9a80adb..2bbc367 100644 --- a/docs/GROWTH-ANALYST-UX-SPEC.md +++ b/docs/GROWTH-ANALYST-UX-SPEC.md @@ -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. - Show historical wins, up to five upcoming opportunities, up to three gentle low-period warnings, and a peer card when permitted. - Each brief contains server-rendered timing/range/evidence plus validated AI headline, interpretation, offer/message suggestion, one preparation step, confidence, and limitation. -- `PARTIAL` keeps deterministic charts/cards and explains that the written AI interpretation was unavailable. `INSUFFICIENT_DATA` explains the minimum evidence without presenting a forecast. `FAILED` offers safe retry without hiding an older successful report. +- `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. ## Copy rules @@ -28,4 +28,4 @@ - Works at 360, 768, 1024, and wide desktop widths in light/dark RTL layouts. - All job, cache, refresh, partial, insufficient, unauthorized, and recoverable error states are accessible and tested. - Generated OpenAPI types are used; no handwritten duplicate growth DTOs or browser secrets exist. -- Cached navigation performs no generation request; manual refresh is explicit and limited once per merchant per Tehran day. +- 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. diff --git a/src/lib/api/schema.d.ts b/src/lib/api/schema.d.ts index 04502de..9ae13cc 100644 --- a/src/lib/api/schema.d.ts +++ b/src/lib/api/schema.d.ts @@ -337,7 +337,7 @@ export interface components { schemas: { CreateGrowthReportRequest: { 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; }; GrowthRange: { @@ -365,7 +365,7 @@ export interface components { /** @enum {integer} */ horizonDays: 90; /** @enum {string} */ - confidence: 'HIGH' | 'MEDIUM' | 'INSUFFICIENT_DATA'; + confidence: 'HIGH' | 'MEDIUM' | 'LOW' | 'INSUFFICIENT_DATA'; suppressionReason?: string; points: components['schemas']['GrowthForecastPoint'][]; backtest: { @@ -498,7 +498,10 @@ export interface components { failureCode?: string; cached: 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; /** Format: date-time */ createdAt: string; diff --git a/src/lib/components/GrowthAIWorkingIllustration.svelte b/src/lib/components/GrowthAIWorkingIllustration.svelte new file mode 100644 index 0000000..4b8ec67 --- /dev/null +++ b/src/lib/components/GrowthAIWorkingIllustration.svelte @@ -0,0 +1,223 @@ + + +
+ + +

{title}

+

+ هوش مصنوعی داده‌ها را کنار هم می‌گذارد، الگوها را بررسی می‌کند و خلاصه‌ای کاربردی از وضعیت رشد شما + می‌سازد. +

+ +
+ + diff --git a/src/lib/components/GrowthAIWorkingIllustration.test.ts b/src/lib/components/GrowthAIWorkingIllustration.test.ts new file mode 100644 index 0000000..125c090 --- /dev/null +++ b/src/lib/components/GrowthAIWorkingIllustration.test.ts @@ -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(); + }); +}); diff --git a/src/lib/components/GrowthForecastChart.svelte b/src/lib/components/GrowthForecastChart.svelte index 87fa3fd..89a4e97 100644 --- a/src/lib/components/GrowthForecastChart.svelte +++ b/src/lib/components/GrowthForecastChart.svelte @@ -214,6 +214,15 @@

شروع پیش‌بینی: {dateLabel(startDate)}. این برآورد قطعی نیست و بر پایه الگوی داده‌های کامل واردشده است.

+ {#if confidence === 'LOW'} +

+ این پیش‌بینی با سابقه محدود یا الگوی پرنوسان ساخته شده است؛ بازه احتمالی را مبنای تصمیم بگیرید، نه + عدد میانی را به‌عنوان نتیجه قطعی. +

+ {/if}
جدول داده‌های نمودار
diff --git a/src/lib/components/GrowthForecastChart.test.ts b/src/lib/components/GrowthForecastChart.test.ts index fa2a9a8..141e21e 100644 --- a/src/lib/components/GrowthForecastChart.test.ts +++ b/src/lib/components/GrowthForecastChart.test.ts @@ -32,4 +32,14 @@ describe('GrowthForecastChart', () => { await fireEvent.click(screen.getByRole('button', { name: 'مبلغ' })); 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(); + }); }); diff --git a/src/lib/components/GrowthPage.svelte b/src/lib/components/GrowthPage.svelte index 659c5e3..36d7d0f 100644 --- a/src/lib/components/GrowthPage.svelte +++ b/src/lib/components/GrowthPage.svelte @@ -10,11 +10,11 @@ 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 { refreshAvailabilityLabel, reportDisplayMode } from '$lib/growth-report-state'; import { formatDate } from '$lib/format'; + import GrowthAIWorkingIllustration from './GrowthAIWorkingIllustration.svelte'; import GrowthForecastChart from './GrowthForecastChart.svelte'; import GrowthInsights from './GrowthInsights.svelte'; - import GrowthReportSkeleton from './GrowthReportSkeleton.svelte'; import PageHeader from './PageHeader.svelte'; const dashboard = useDashboardContext(); @@ -201,7 +201,9 @@ > {:else if (loading && !visibleReport) || displayMode === 'loading'} - + {:else if displayMode === 'error' || !report || !visibleReport || !evidence} به‌روزرسانی تحلیل - نوبت بعدی: {formatDate(report.nextRefreshAt)} + + {refreshAvailabilityLabel( + visibleReport.status === 'READY' && Boolean(visibleReport.aiCopy?.briefs.length), + formatDate(report.nextRefreshAt), + )} +
@@ -252,13 +259,12 @@ {/if} - {#if evidence.forecast.confidence === 'INSUFFICIENT_DATA'} + {#if evidence.forecast.points.length === 0}

برای پیش‌بینی قابل اتکا هنوز داده کافی نداریم

+ >

هنوز داده‌ای برای ساخت پیش‌بینی وجود ندارد

- این بخش فقط زمانی پیش‌بینی نشان می‌دهد که سابقه کافی برای مقایسه روزهای مشابه و ارزیابی دقت مدل - وجود داشته باشد. + پس از ثبت نخستین داده کامل، مدل پیش‌بینی را با درجه اطمینان متناسب نمایش می‌دهد.

@@ -279,6 +285,11 @@ /> {/if} - {#if refreshing || isRunning}{/if} + {#if refreshing || isRunning} + + {/if} {/if} diff --git a/src/lib/growth-chart.test.ts b/src/lib/growth-chart.test.ts index 6f88fcb..86b104b 100644 --- a/src/lib/growth-chart.test.ts +++ b/src/lib/growth-chart.test.ts @@ -29,6 +29,6 @@ describe('growth forecast chart presentation', () => { it('translates forecast confidence without implying certainty', () => { expect(formatConfidence('HIGH')).toBe('اطمینان بالا'); expect(formatConfidence('MEDIUM')).toBe('اطمینان متوسط'); - expect(formatConfidence('INSUFFICIENT')).toBe('داده ناکافی'); + expect(formatConfidence('LOW')).toBe('اطمینان پایین'); }); }); diff --git a/src/lib/growth-chart.ts b/src/lib/growth-chart.ts index 53b3b17..7246a71 100644 --- a/src/lib/growth-chart.ts +++ b/src/lib/growth-chart.ts @@ -43,5 +43,6 @@ export function buildForecastChart(data: ForecastChartDatum[], width: number, he export function formatConfidence(value: string): string { if (value === 'HIGH') return 'اطمینان بالا'; if (value === 'MEDIUM') return 'اطمینان متوسط'; + if (value === 'LOW') return 'اطمینان پایین'; return 'داده ناکافی'; } diff --git a/src/lib/growth-report-state.test.ts b/src/lib/growth-report-state.test.ts index af057ea..fd0283f 100644 --- a/src/lib/growth-report-state.test.ts +++ b/src/lib/growth-report-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { reportDisplayMode } from './growth-report-state'; +import { refreshAvailabilityLabel, reportDisplayMode } from './growth-report-state'; describe('growth report display 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'); }); }); + +describe('growth report refresh availability', () => { + it('keeps retries visibly available until an AI summary exists', () => { + expect(refreshAvailabilityLabel(false, '۱ شهریور')).toBe( + 'تا آماده‌شدن توضیح هوشمند، تلاش دوباره محدود نیست.', + ); + expect(refreshAvailabilityLabel(true, '۱ شهریور')).toBe('نوبت بعدی: ۱ شهریور'); + }); +}); diff --git a/src/lib/growth-report-state.ts b/src/lib/growth-report-state.ts index 8621a44..4ffdd83 100644 --- a/src/lib/growth-report-state.ts +++ b/src/lib/growth-report-state.ts @@ -9,3 +9,9 @@ export function reportDisplayMode( if (status === 'QUEUED' || status === 'ANALYZING' || status === 'GENERATING') return 'loading'; return 'error'; } + +export function refreshAvailabilityLabel(hasAISummary: boolean, nextRefreshLabel: string): string { + return hasAISummary + ? `نوبت بعدی: ${nextRefreshLabel}` + : 'تا آماده‌شدن توضیح هوشمند، تلاش دوباره محدود نیست.'; +}