feat: improve growth analysis waiting experience
This commit is contained in:
@@ -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.
|
||||
|
||||
9
src/lib/api/schema.d.ts
vendored
9
src/lib/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
223
src/lib/components/GrowthAIWorkingIllustration.svelte
Normal file
223
src/lib/components/GrowthAIWorkingIllustration.svelte
Normal 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>
|
||||
32
src/lib/components/GrowthAIWorkingIllustration.test.ts
Normal file
32
src/lib/components/GrowthAIWorkingIllustration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -214,6 +214,15 @@
|
||||
<p class="text-muted-foreground px-3 pb-4 text-xs">
|
||||
شروع پیشبینی: {dateLabel(startDate)}. این برآورد قطعی نیست و بر پایه الگوی دادههای کامل واردشده است.
|
||||
</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">
|
||||
<summary class="font-semibold">جدول دادههای نمودار</summary>
|
||||
<div class="mt-3 max-h-72 overflow-auto">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 @@
|
||||
></Card
|
||||
>
|
||||
{: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}
|
||||
<Card class="border-destructive/30 rounded-lg"
|
||||
><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}
|
||||
><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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -252,13 +259,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if evidence.forecast.confidence === 'INSUFFICIENT_DATA'}
|
||||
{#if evidence.forecast.points.length === 0}
|
||||
<Card class="rounded-lg"
|
||||
><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></CardContent
|
||||
></Card
|
||||
>
|
||||
@@ -279,6 +285,11 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if refreshing || isRunning}<GrowthReportSkeleton refreshing />{/if}
|
||||
{#if refreshing || isRunning}
|
||||
<GrowthAIWorkingIllustration
|
||||
overlay
|
||||
status={report.status === 'ANALYZING' || report.status === 'GENERATING' ? report.status : 'QUEUED'}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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('اطمینان پایین');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 'داده ناکافی';
|
||||
}
|
||||
|
||||
@@ -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('نوبت بعدی: ۱ شهریور');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`
|
||||
: 'تا آمادهشدن توضیح هوشمند، تلاش دوباره محدود نیست.';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user