feat: visualize ranked event opportunities
This commit is contained in:
@@ -132,6 +132,7 @@ The approved Persian-first baseline remains authoritative where the shipped arti
|
||||
|
||||
| Version | Date | Decision |
|
||||
| ------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0.13.0 | 2026-08-22 | Replaced equal upcoming-event cards with an RTL shared-baseline priority histogram and ranked evidence cards. Three positive integer shares descend from physical right and total ۱۰۰٪; visible copy distinguishes planning priority from growth probability. Historical occasion evidence takes precedence over onboarding-profile signals, with explicit source and promote/prepare/caution labels. |
|
||||
| 0.12.0 | 2026-08-22 | Replaced the Transactions table with a period-scoped operational-health dashboard: four status KPIs, an absolute-count distribution chart, a 100% composition trend, keyboard inspection, and visible semantic data-table alternatives. Status colors are fixed to blue Paid/awaiting verification, emerald Verified/final, rose Failed, and amber Reversed. |
|
||||
| 0.11.1 | 2026-08-21 | Hardened narrow-screen shell, calendar, and chart behavior: the navbar uses the compact authentication-screen brand mark while the opened mobile drawer carries the full typographic wordmark and the only terminal selector, exact-period fields cannot overlap, payment charts keep their readable width with horizontal scrolling, and the growth teaser gains an action-blue emphasis with a restrained reduced-motion-safe pulse. |
|
||||
| 0.11.0 | 2026-08-21 | Added the feature-gated AI growth analyst: a cache-first 90-day forecast view with count/amount modes, P10–P90 uncertainty band, keyboard and tabular alternatives, evidence-linked Persian campaign guidance, preserved-content refresh states, and restrained semantic opportunity/risk styling. |
|
||||
@@ -311,6 +312,14 @@ The Transactions page contains no rows or drill-down links. Its primary chart us
|
||||
|
||||
The fee page gives the circular share chart a large physical-right column while three numeric KPI cards stack in the adjacent column. Peer progress bars move from rose through amber and sky to emerald as the direction-adjusted percentile improves. Retry guidance uses amber, sky, or emerald surfaces and changes its supportive sentence at tested recovery/sample thresholds.
|
||||
|
||||
### Event Planning Priority
|
||||
|
||||
The growth page ranks up to three upcoming occasions in one shared-baseline histogram. Rank 1 is the physical-right column in RTL; all available columns remain side by side on phones because their relative heights are the comparison. Column height maps literally to an integer planning-priority share, and the visible percentages total ۱۰۰٪. When only one or two candidates exist, a visible note names that limited set and the chart divides attention only among those events. A nearby clarification says these values allocate planning attention and are not growth probabilities.
|
||||
|
||||
The histogram is the section's single visual signature: rank 1 uses the primary data color, while ranks 2 and 3 use progressively quieter mixtures of the same token and the nested surface. It uses no gradient, floating shadow, or decorative motion. Height changes may transition for 240ms and become immediate under reduced motion. Rank, title, percentage, total, and an ordered screen-reader description make the chart understandable without color or pointer interaction.
|
||||
|
||||
Evidence cards stack beneath the compact chart on phones and form up to three physical-right-to-left columns from the medium breakpoint. Each card shows the event range, reason, explicit stance, and one source badge: «بر پایه تجربه شما» or «بر پایه شناخت کسبوکار». Historical cards show the previous occasion range, observation count, paid-session uplift, and amount uplift. Profile-grounded cards show onboarding-derived category, niche, or channel signals; if none are available, they say that only general profile context was used. Typed history-unavailable codes are translated into natural Persian. A visible warning identifies deterministic `PARTIAL` copy. Success, action, or warning accents reinforce the written promote, prepare, or caution stance and never replace it.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### Do:
|
||||
|
||||
@@ -12,8 +12,14 @@
|
||||
- Header states the 90-day horizon starts after the latest complete dataset date. A daily `بهروزرسانی تحلیل` action shows `nextRefreshAt`, loading, and typed rate-limit feedback.
|
||||
- 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.
|
||||
- Rank up to three upcoming events as a shared-baseline priority histogram. Rank 1 is the physical-right column in RTL. Each column's literal height equals its integer planning-priority percentage; the displayed percentages are positive, descend by rank, and total exactly `100`. When only one or two relevant candidates exist, say so explicitly and divide the total only among the available candidates.
|
||||
- State visibly that event priority is a relative planning allocation, not a probability of growth or guaranteed demand. Repeat rank and percentage in text so height and color are never the only carriers of meaning.
|
||||
- Pair the compact histogram with one evidence card per displayed event. On desktop, up to three cards form RTL columns aligned by rank; on mobile, the compact histogram keeps its shared baseline and evidence cards stack for readable analysis.
|
||||
- Prefer evidence from the merchant's matching prior occasion window. Show the prior start/end dates, observation count, paid-session uplift, amount uplift, and an explicit promote, prepare, or caution stance. Material decline must produce a visible caution recommendation.
|
||||
- When matching historical evidence is unavailable, ground the explanation in onboarding profile signals. Show the matched category, niche, or sales-channel signals and, when provided, the reason history was unavailable.
|
||||
- Label every event explanation as either `بر پایه تجربه شما` or `بر پایه شناخت کسبوکار`. The label must agree with both deterministic evidence and the recommendation's `insightBasis`.
|
||||
- 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. `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.
|
||||
- `PARTIAL` keeps deterministic charts/cards and visibly explains that the written AI interpretation was unavailable. `INSUFFICIENT_EVENT_CANDIDATES` additionally states the exact number of available events instead of implying that three were ranked. `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
|
||||
@@ -21,11 +27,25 @@
|
||||
- Use natural professional Persian, Persian `ی`/`ک`, appropriate نیمفاصله, Persian digits, and Rial.
|
||||
- Keep tone constructive: describe opportunity, preparation, and uncertainty; do not shame the merchant or claim guaranteed outcomes.
|
||||
- Every recommendation visibly cites its deterministic evidence card. AI is labeled advisory.
|
||||
- Event reasons explain why the occasion is relevant to this merchant and must not turn planning priority into a forecast claim.
|
||||
- No chat, notifications, campaign execution, user memory, provider/model names, token usage, or internal failure details appear in merchant UI.
|
||||
|
||||
## Event priority contract
|
||||
|
||||
- `occasionKey` is the stable identity used to join an upcoming occurrence to prior occurrences; `eventVariant` is one of `PRIMARY`, `CAMPAIGN_WINDOW`, or `OPERATIONAL_WINDOW` and distinguishes materially different variants without changing that identity.
|
||||
- `HISTORICAL` is preferred whenever matching prior-window evidence is eligible. `BUSINESS_PROFILE` is used only when that evidence is unavailable.
|
||||
- `historicalEvidence` contains deterministic dates, sample size, basis-point uplifts, and material-win/material-decline flags. AI copy may interpret these values but may not invent or alter them.
|
||||
- `profileEvidence.matchedSignals` contains onboarding-derived evidence and may be empty. Empty signals produce truthful general-profile copy rather than invented alignment chips. `historyUnavailableReason` is an optional typed code and is always translated to natural Persian; raw backend codes never appear.
|
||||
- Deterministic events form a discriminated evidence union: `HISTORICAL` requires `historicalEvidence`, while `BUSINESS_PROFILE` requires `profileEvidence`. Recommendation basis must match the deterministic event basis; mismatched recommendation copy is discarded rather than overriding evidence authority.
|
||||
- `priorityPercent` is an integer from `1` to `98`. A valid AI recommendation collection contains exactly three items, is sorted descending, and totals exactly `100`; reports with fewer candidates omit AI copy and use the explicit limited-candidate `PARTIAL` state.
|
||||
- Upcoming-event filtering compares inclusive calendar dates in `Asia/Tehran`, so an event remains visible through its entire `endDate` in the merchant calendar.
|
||||
- `stance` is one of `PROMOTE`, `PREPARE`, or `CAUTION`; color always appears with its Persian text label.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Works at 360, 768, 1024, and wide desktop widths in light/dark RTL layouts.
|
||||
- The event chart keeps three readable columns at 360 px, places rank 1 physically right, and exposes an equivalent ordered text alternative to assistive technology.
|
||||
- Event evidence and reasons are visible without hover or pointer interaction, and histogram height transitions are removed under `prefers-reduced-motion`.
|
||||
- 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 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.
|
||||
|
||||
58
src/lib/api/schema.d.ts
vendored
58
src/lib/api/schema.d.ts
vendored
@@ -907,19 +907,73 @@ export interface components {
|
||||
expectedAmountRial: components["schemas"]["GrowthRange"];
|
||||
historicalEvidenceId: string;
|
||||
};
|
||||
GrowthUpcomingEvent: {
|
||||
GrowthHistoricalEventEvidence: {
|
||||
/** Format: date-time */
|
||||
priorStartDate: string;
|
||||
/** Format: date-time */
|
||||
priorEndDate: string;
|
||||
/** @description Number of eligible comparison windows used to establish the baseline. */
|
||||
observationCount: number;
|
||||
/** Format: int64 */
|
||||
paidSessionUpliftBps: number;
|
||||
/** Format: int64 */
|
||||
amountUpliftBps: number;
|
||||
materialWin: boolean;
|
||||
materialDecline: boolean;
|
||||
};
|
||||
GrowthProfileEventEvidence: {
|
||||
matchedSignals: string[];
|
||||
historyUnavailableReason?: components["schemas"]["GrowthHistoryUnavailableReason"];
|
||||
};
|
||||
/** @enum {string} */
|
||||
GrowthHistoryUnavailableReason: "INSUFFICIENT_BASELINE_OBSERVATIONS" | "INSUFFICIENT_BASELINE_SESSIONS" | "INSUFFICIENT_HISTORY" | "INCOMPLETE_EVENT_WINDOW" | "INSUFFICIENT_COHORT" | "TERMINAL_SCOPE_UNSUPPORTED";
|
||||
GrowthUpcomingEventCommon: {
|
||||
evidenceId: string;
|
||||
eventId: string;
|
||||
occasionKey: string;
|
||||
/** @enum {string} */
|
||||
eventVariant: "PRIMARY" | "CAMPAIGN_WINDOW" | "OPERATIONAL_WINDOW";
|
||||
eventTitleFa: string;
|
||||
/** Format: date-time */
|
||||
date: string;
|
||||
/** Format: date-time */
|
||||
endDate: string;
|
||||
eventType: string;
|
||||
officialHoliday: boolean;
|
||||
merchantCategories: string[];
|
||||
behavioralChannels: string[];
|
||||
};
|
||||
GrowthUpcomingHistoricalEvent: components["schemas"]["GrowthUpcomingEventCommon"] & {
|
||||
/** @enum {string} */
|
||||
insightBasis: "HISTORICAL";
|
||||
historicalEvidence: components["schemas"]["GrowthHistoricalEventEvidence"];
|
||||
} & {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
insightBasis: "HISTORICAL";
|
||||
};
|
||||
GrowthUpcomingProfileEvent: components["schemas"]["GrowthUpcomingEventCommon"] & {
|
||||
/** @enum {string} */
|
||||
insightBasis: "BUSINESS_PROFILE";
|
||||
profileEvidence: components["schemas"]["GrowthProfileEventEvidence"];
|
||||
} & {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
insightBasis: "BUSINESS_PROFILE";
|
||||
};
|
||||
GrowthUpcomingEvent: components["schemas"]["GrowthUpcomingHistoricalEvent"] | components["schemas"]["GrowthUpcomingProfileEvent"];
|
||||
/** @description Exactly three recommendations are returned in descending priority order. Their positive integer priorityPercent values sum to 100. */
|
||||
GrowthEventRecommendation: {
|
||||
evidenceId: string;
|
||||
priorityPercent: number;
|
||||
/** @enum {string} */
|
||||
stance: "PROMOTE" | "PREPARE" | "CAUTION";
|
||||
/** @enum {string} */
|
||||
insightBasis: "HISTORICAL" | "BUSINESS_PROFILE";
|
||||
relevanceReason: string;
|
||||
};
|
||||
GrowthRisk: {
|
||||
@@ -977,6 +1031,7 @@ export interface components {
|
||||
category: string;
|
||||
niches: string[];
|
||||
salesChannels: string[];
|
||||
businessDescriptions?: string[];
|
||||
};
|
||||
};
|
||||
GrowthReport: {
|
||||
@@ -991,6 +1046,7 @@ export interface components {
|
||||
horizonDays: 90;
|
||||
deterministicEvidence?: components["schemas"]["GrowthEvidence"];
|
||||
aiCopy?: {
|
||||
/** @description Ranked in descending priorityPercent order; the three percentages sum to exactly 100. */
|
||||
eventRecommendations: components["schemas"]["GrowthEventRecommendation"][];
|
||||
};
|
||||
failureCode?: string;
|
||||
|
||||
@@ -1,53 +1,314 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/api/schema';
|
||||
import { formatDateOnly } from '$lib/format';
|
||||
import { formatDateOnly, formatPercent, formatValue } from '$lib/format';
|
||||
|
||||
type UpcomingEvent = components['schemas']['GrowthUpcomingEvent'];
|
||||
type EventRecommendation = components['schemas']['GrowthEventRecommendation'];
|
||||
type HistoryUnavailableReason = components['schemas']['GrowthHistoryUnavailableReason'];
|
||||
type RecommendationPresentation = Pick<
|
||||
EventRecommendation,
|
||||
'priorityPercent' | 'stance' | 'relevanceReason'
|
||||
>;
|
||||
type UnrankedEvent<T = UpcomingEvent> = T extends UpcomingEvent ? T & RecommendationPresentation : never;
|
||||
type RankedEvent<T = UpcomingEvent> = T extends UpcomingEvent
|
||||
? T & RecommendationPresentation & { rank: number }
|
||||
: never;
|
||||
|
||||
let {
|
||||
events = [],
|
||||
recommendations = [],
|
||||
isPartial = false,
|
||||
now = new Date(),
|
||||
}: {
|
||||
events?: UpcomingEvent[];
|
||||
recommendations?: EventRecommendation[];
|
||||
isPartial?: boolean;
|
||||
now?: Date;
|
||||
} = $props();
|
||||
|
||||
const visibleEvents = $derived.by(() => {
|
||||
const byEvidenceId = new Map(events.map((event) => [event.evidenceId, event]));
|
||||
const ranked = recommendations
|
||||
.map((recommendation) => ({
|
||||
...byEvidenceId.get(recommendation.evidenceId),
|
||||
relevanceReason: recommendation.relevanceReason,
|
||||
}))
|
||||
.filter(
|
||||
(event): event is UpcomingEvent & { relevanceReason: string } =>
|
||||
Boolean(event.evidenceId) && new Date(event.date) >= now,
|
||||
);
|
||||
if (ranked.length) return ranked.slice(0, 3);
|
||||
return events
|
||||
.filter((event) => new Date(event.date) >= now)
|
||||
.toSorted((a, b) => Date.parse(a.date) - Date.parse(b.date))
|
||||
.slice(0, 3)
|
||||
.map((event) => ({
|
||||
...event,
|
||||
relevanceReason: 'این مناسبت در تقویم بررسیشده بهعنوان رویدادی مرتبط با کسبوکار ثبت شده است.',
|
||||
}));
|
||||
const stanceLabels: Record<EventRecommendation['stance'], string> = {
|
||||
PROMOTE: 'پیشنهاد: ترویج',
|
||||
PREPARE: 'پیشنهاد: آمادهسازی',
|
||||
CAUTION: 'پیشنهاد: با احتیاط',
|
||||
};
|
||||
|
||||
const historyUnavailableLabels: Record<HistoryUnavailableReason, string> = {
|
||||
INSUFFICIENT_BASELINE_OBSERVATIONS: 'تعداد بازههای قابلمقایسه برای این مناسبت کافی نبوده است.',
|
||||
INSUFFICIENT_BASELINE_SESSIONS: 'تعداد پرداختهای دورهٔ مشابه برای نتیجهگیری مطمئن کافی نبوده است.',
|
||||
INSUFFICIENT_HISTORY: 'هنوز سابقهٔ کافی برای این مناسبت ثبت نشده است.',
|
||||
INCOMPLETE_EVENT_WINDOW: 'دادهٔ دورهٔ مشابه قبلی هنوز کامل نیست.',
|
||||
INSUFFICIENT_COHORT: 'دادهٔ کافی برای انتخاب یک گروه مقایسهٔ معتبر وجود ندارد.',
|
||||
TERMINAL_SCOPE_UNSUPPORTED: 'تجربهٔ این مناسبت در محدودهٔ پایانهٔ انتخابی قابلمقایسه نیست.',
|
||||
};
|
||||
|
||||
const tehranDateFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
function tehranCalendarKey(value: string | Date): string | null {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const parts = Object.fromEntries(
|
||||
tehranDateFormatter
|
||||
.formatToParts(date)
|
||||
.filter((part) => part.type !== 'literal')
|
||||
.map((part) => [part.type, part.value]),
|
||||
);
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
function historyUnavailableCopy(reason: string): string {
|
||||
return (
|
||||
historyUnavailableLabels[reason as HistoryUnavailableReason] ??
|
||||
'دادهٔ همان مناسبت در دورهٔ قبل برای تحلیل مطمئن کافی نبوده است.'
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackReason(event: UpcomingEvent): string {
|
||||
if (event.insightBasis === 'HISTORICAL' && event.historicalEvidence.materialDecline) {
|
||||
return 'داده دوره مشابه قبلی، افت معنادار را نشان میدهد؛ برنامه را محدود و مرحلهای اجرا کنید.';
|
||||
}
|
||||
if (event.insightBasis === 'HISTORICAL') {
|
||||
return 'رفتار پرداخت در دوره مشابه قبلی، این مناسبت را برای برنامهریزی دوباره قابل بررسی میکند.';
|
||||
}
|
||||
if (event.profileEvidence.matchedSignals.length === 0) {
|
||||
return 'این مناسبت با توجه به شناخت کلی ثبتشده از کسبوکار شما برای بررسی بیشتر انتخاب شده است.';
|
||||
}
|
||||
return 'ویژگیهای این مناسبت با نشانههای ثبتشده در پروفایل کسبوکار شما همخوانی دارد.';
|
||||
}
|
||||
|
||||
const visibleEvents = $derived.by(() => {
|
||||
const todayKey = tehranCalendarKey(now);
|
||||
const upcoming = events.filter((event) => {
|
||||
const endKey = tehranCalendarKey(event.endDate);
|
||||
return todayKey !== null && endKey !== null && endKey >= todayKey;
|
||||
});
|
||||
const byEvidenceId = new Map(upcoming.map((event) => [event.evidenceId, event]));
|
||||
const ranked = recommendations
|
||||
.map((recommendation) => {
|
||||
const event = byEvidenceId.get(recommendation.evidenceId);
|
||||
if (!event || event.insightBasis !== recommendation.insightBasis) return null;
|
||||
return {
|
||||
...event,
|
||||
priorityPercent: recommendation.priorityPercent,
|
||||
stance: recommendation.stance,
|
||||
relevanceReason: recommendation.relevanceReason,
|
||||
} satisfies UnrankedEvent;
|
||||
})
|
||||
.filter((event): event is NonNullable<typeof event> => event !== null)
|
||||
.toSorted((a, b) => b.priorityPercent - a.priorityPercent);
|
||||
|
||||
const validRankedSet =
|
||||
recommendations.length === 3 &&
|
||||
ranked.length === 3 &&
|
||||
new Set(ranked.map((event) => event.evidenceId)).size === 3 &&
|
||||
ranked.every((event) => Number.isInteger(event.priorityPercent) && event.priorityPercent > 0) &&
|
||||
ranked.reduce((sum, event) => sum + event.priorityPercent, 0) === 100;
|
||||
|
||||
if (validRankedSet) {
|
||||
return ranked.map((event, index) => ({ ...event, rank: index + 1 })) as RankedEvent[];
|
||||
}
|
||||
|
||||
const candidates = upcoming.toSorted((a, b) => Date.parse(a.date) - Date.parse(b.date)).slice(0, 3);
|
||||
if (candidates.length === 0) return [];
|
||||
const baseShare = Math.floor(100 / candidates.length);
|
||||
const remainder = 100 - baseShare * candidates.length;
|
||||
return candidates.map((event, index) => ({
|
||||
...event,
|
||||
rank: index + 1,
|
||||
priorityPercent: baseShare + (index < remainder ? 1 : 0),
|
||||
stance:
|
||||
event.insightBasis === 'HISTORICAL' && event.historicalEvidence.materialDecline
|
||||
? ('CAUTION' as const)
|
||||
: ('PREPARE' as const),
|
||||
relevanceReason: fallbackReason(event),
|
||||
})) as RankedEvent[];
|
||||
});
|
||||
|
||||
const priorityTotal = $derived(visibleEvents.reduce((total, event) => total + event.priorityPercent, 0));
|
||||
const eventCountLabel = $derived(visibleEvents.length === 3 ? 'سه' : formatValue(visibleEvents.length));
|
||||
|
||||
function safePriority(value: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, Math.min(100, Math.round(value))) : 0;
|
||||
}
|
||||
|
||||
function bps(value: number): string {
|
||||
return formatPercent(String(Math.abs(value) / 100));
|
||||
}
|
||||
|
||||
function movement(value: number): string {
|
||||
if (value > 0) return `${bps(value)} افزایش`;
|
||||
if (value < 0) return `${bps(value)} کاهش`;
|
||||
return 'بدون تغییر';
|
||||
}
|
||||
|
||||
function dateRange(start: string, end: string): string {
|
||||
const formattedStart = formatDateOnly(start);
|
||||
const formattedEnd = formatDateOnly(end);
|
||||
return formattedStart === formattedEnd ? formattedStart : `${formattedStart} تا ${formattedEnd}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visibleEvents.length}
|
||||
<div class="grid gap-3 sm:grid-cols-3" role="list" aria-label="رویدادهای پیش رو">
|
||||
{#each visibleEvents as event (event.evidenceId)}
|
||||
<article class="bg-muted/35 rounded-lg border p-4 text-right" role="listitem">
|
||||
<p class="text-muted-foreground text-xs">رویداد پیش رو</p>
|
||||
<h3 class="mt-2 text-sm font-bold">{event.eventTitleFa}</h3>
|
||||
<p class="text-muted-foreground mt-2 text-xs">{formatDateOnly(event.date)}</p>
|
||||
<p class="text-muted-foreground mt-3 text-xs leading-5">{event.relevanceReason}</p>
|
||||
</article>
|
||||
{/each}
|
||||
<div class="space-y-5" dir="rtl">
|
||||
{#if isPartial}
|
||||
<div
|
||||
class="border-warning/35 bg-warning/10 rounded-lg border p-3 text-sm leading-6"
|
||||
role="status"
|
||||
aria-label="تحلیل تکمیلی موقت"
|
||||
>
|
||||
<p class="font-bold">نسخهٔ موقت تحلیل</p>
|
||||
<p>
|
||||
{visibleEvents.length < 3
|
||||
? 'رویدادهای مرتبط کافی برای رتبهبندی کامل پیدا نشد؛ موارد موجود بهصورت موقت نمایش داده میشوند.'
|
||||
: 'متن تفسیری هوشمند این نوبت آماده نشد؛ اولویتهای زیر از شواهد موجود ساخته شدهاند.'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if visibleEvents.length < 3}
|
||||
<div class="bg-muted/35 rounded-lg border p-3 text-sm leading-6">
|
||||
تنها {formatValue(visibleEvents.length)} رویداد مرتبط در بازهٔ بررسی پیدا شد؛ سهمهای نمایشدادهشده فقط
|
||||
میان همین موارد تقسیم شدهاند.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="bg-accent/55 border-primary/15 flex gap-3 rounded-lg border p-3 text-sm leading-6">
|
||||
<span class="text-primary mt-0.5 font-black" aria-hidden="true">٪</span>
|
||||
<p>
|
||||
این درصد، سهم اولویت برای برنامهریزی است؛ نه احتمال رشد فروش. جمع
|
||||
{visibleEvents.length === 3 ? 'سه اولویت' : `اولویتهای ${formatValue(visibleEvents.length)} رویداد`}
|
||||
۱۰۰٪ است.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<figure
|
||||
class="bg-muted/25 rounded-lg border px-3 pt-4 pb-3 sm:px-5 sm:pt-5"
|
||||
aria-label={`نمودار اولویت برنامهریزی ${eventCountLabel} رویداد پیش رو`}
|
||||
aria-describedby="event-priority-description"
|
||||
>
|
||||
<figcaption id="event-priority-description" class="sr-only">
|
||||
نمودار ستونی رتبهبندی رویدادها؛ ارتفاع هر ستون برابر سهم آن رویداد از اولویت برنامهریزی است.
|
||||
</figcaption>
|
||||
<div
|
||||
class="event-grid grid h-44 gap-3 border-b sm:h-56 sm:gap-5"
|
||||
style={`--event-count: ${visibleEvents.length}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each visibleEvents as event (event.evidenceId)}
|
||||
<div class="flex min-w-0 flex-col items-center justify-end">
|
||||
<strong class="text-foreground mb-1 text-sm font-black tabular-nums">
|
||||
{formatValue(event.priorityPercent)}٪
|
||||
</strong>
|
||||
<div
|
||||
data-testid="priority-bar"
|
||||
class:rank-one={event.rank === 1}
|
||||
class:rank-two={event.rank === 2}
|
||||
class:rank-three={event.rank === 3}
|
||||
class="priority-bar w-full max-w-24 rounded-t-md"
|
||||
style={`--priority: ${safePriority(event.priorityPercent)}`}
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="event-grid grid gap-3 pt-2 sm:gap-5" style={`--event-count: ${visibleEvents.length}`}>
|
||||
{#each visibleEvents as event (event.evidenceId)}
|
||||
<div class="min-w-0 text-center">
|
||||
<span class="text-primary text-xs font-bold">رتبه {formatValue(event.rank)}</span>
|
||||
<p class="mt-0.5 truncate text-xs font-bold sm:text-sm">{event.eventTitleFa}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-muted-foreground mt-3 text-center text-xs tabular-nums">
|
||||
جمع اولویتها: {formatValue(priorityTotal)}٪
|
||||
</p>
|
||||
<ol class="sr-only">
|
||||
{#each visibleEvents as event (event.evidenceId)}
|
||||
<li>
|
||||
رتبه {formatValue(event.rank)}، {event.eventTitleFa}، {formatValue(event.priorityPercent)} درصد از
|
||||
اولویت برنامهریزی.
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</figure>
|
||||
|
||||
<section class="grid gap-3 md:grid-cols-3" aria-label="دلایل و شواهد اولویت رویدادها">
|
||||
{#each visibleEvents as event (event.evidenceId)}
|
||||
<article
|
||||
class:stance-promote={event.stance === 'PROMOTE'}
|
||||
class:stance-prepare={event.stance === 'PREPARE'}
|
||||
class:stance-caution={event.stance === 'CAUTION'}
|
||||
class="event-card bg-card flex min-w-0 flex-col rounded-lg border p-4 text-right"
|
||||
aria-label={`رتبه ${formatValue(event.rank)}: ${event.eventTitleFa}`}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-primary text-xs font-bold">
|
||||
رتبه {formatValue(event.rank)} · {formatValue(event.priorityPercent)}٪
|
||||
</p>
|
||||
<h3 class="mt-1 text-sm font-black">{event.eventTitleFa}</h3>
|
||||
</div>
|
||||
<span class="basis-badge shrink-0 rounded-full px-2 py-1 text-xs font-bold">
|
||||
{event.insightBasis === 'HISTORICAL' ? 'بر پایه تجربه شما' : 'بر پایه شناخت کسبوکار'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground mt-2 text-xs">
|
||||
زمان رویداد: {dateRange(event.date, event.endDate)}
|
||||
</p>
|
||||
<p class="stance-label mt-3 text-xs font-bold">{stanceLabels[event.stance]}</p>
|
||||
<p class="reason mt-3 border-r-2 py-1 pr-3 text-sm leading-7">{event.relevanceReason}</p>
|
||||
|
||||
{#if event.insightBasis === 'HISTORICAL' && event.historicalEvidence}
|
||||
<div class="bg-muted/45 mt-4 rounded-md border p-3">
|
||||
<p class="text-xs font-bold">دوره مشابه قبلی</p>
|
||||
<p class="text-muted-foreground mt-1 text-xs">
|
||||
{dateRange(event.historicalEvidence.priorStartDate, event.historicalEvidence.priorEndDate)} ·
|
||||
{formatValue(event.historicalEvidence.observationCount)} بازه مقایسهشده
|
||||
</p>
|
||||
<dl class="mt-3 grid gap-2 text-xs">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt>تعداد پرداخت موفق</dt>
|
||||
<dd class="font-bold tabular-nums">
|
||||
{movement(event.historicalEvidence.paidSessionUpliftBps)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt>مبلغ فروش</dt>
|
||||
<dd class="font-bold tabular-nums">{movement(event.historicalEvidence.amountUpliftBps)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
{:else if event.insightBasis === 'BUSINESS_PROFILE' && event.profileEvidence}
|
||||
<div class="bg-muted/45 mt-4 rounded-md border p-3">
|
||||
<p class="text-xs font-bold">نشانههای مرتبط در پروفایل شما</p>
|
||||
{#if event.profileEvidence.matchedSignals.length}
|
||||
<ul class="mt-2 flex flex-wrap gap-1.5" aria-label="نشانههای مرتبط کسبوکار">
|
||||
{#each event.profileEvidence.matchedSignals as signal (signal)}
|
||||
<li class="bg-background rounded-full border px-2 py-0.5 text-xs">{signal}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-muted-foreground mt-2 text-xs leading-5">
|
||||
این پیشنهاد با شناخت کلی ثبتشده از کسبوکار شما تنظیم شده است؛ نشانهٔ جزئیتری برای نمایش
|
||||
وجود ندارد.
|
||||
</p>
|
||||
{/if}
|
||||
{#if event.profileEvidence.historyUnavailableReason}
|
||||
<p class="text-muted-foreground mt-3 text-xs leading-5">
|
||||
دلیل استفاده از پروفایل: {historyUnavailableCopy(
|
||||
event.profileEvidence.historyUnavailableReason,
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="py-10 text-center" role="status">
|
||||
@@ -55,3 +316,64 @@
|
||||
<p class="text-muted-foreground mt-1 text-sm">با بهروزرسانی تقویم، این بخش دوباره بررسی میشود.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.event-grid {
|
||||
grid-template-columns: repeat(var(--event-count), minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.priority-bar {
|
||||
height: calc(var(--priority) * 1%);
|
||||
transition: height 240ms ease-out;
|
||||
}
|
||||
|
||||
.rank-one {
|
||||
background: var(--primary);
|
||||
}
|
||||
.rank-two {
|
||||
background: color-mix(in srgb, var(--primary) 72%, var(--muted));
|
||||
}
|
||||
.rank-three {
|
||||
background: color-mix(in srgb, var(--primary) 46%, var(--muted));
|
||||
}
|
||||
|
||||
.event-card {
|
||||
border-top-width: 3px;
|
||||
}
|
||||
.stance-promote {
|
||||
border-top-color: var(--success);
|
||||
}
|
||||
.stance-prepare {
|
||||
border-top-color: var(--primary);
|
||||
}
|
||||
.stance-caution {
|
||||
border-top-color: var(--warning);
|
||||
}
|
||||
|
||||
.stance-promote .stance-label {
|
||||
color: var(--success);
|
||||
}
|
||||
.stance-prepare .stance-label {
|
||||
color: var(--primary);
|
||||
}
|
||||
.stance-caution .stance-label {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.basis-badge {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.reason {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.stance-caution .reason {
|
||||
border-color: var(--warning);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.priority-bar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,97 +1,274 @@
|
||||
import { cleanup, render, screen } from '@testing-library/svelte';
|
||||
import { cleanup, render, screen, within } from '@testing-library/svelte';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import GrowthEventImpactChart from './GrowthEventImpactChart.svelte';
|
||||
|
||||
const events = [
|
||||
{
|
||||
evidenceId: 'event-1',
|
||||
eventId: 'event-1',
|
||||
eventTitleFa: 'نوروز',
|
||||
date: '2027-03-21T00:00:00Z',
|
||||
eventType: 'SEASONAL',
|
||||
officialHoliday: false,
|
||||
merchantCategories: [],
|
||||
behavioralChannels: [],
|
||||
},
|
||||
{
|
||||
evidenceId: 'event-2',
|
||||
eventId: 'event-2',
|
||||
eventTitleFa: 'شب یلدا',
|
||||
date: '2026-12-21T00:00:00Z',
|
||||
eventType: 'CULTURAL',
|
||||
officialHoliday: false,
|
||||
merchantCategories: [],
|
||||
behavioralChannels: [],
|
||||
},
|
||||
{
|
||||
evidenceId: 'event-3',
|
||||
eventId: 'event-3',
|
||||
evidenceId: 'event-black-friday',
|
||||
eventId: 'black-friday-2026',
|
||||
occasionKey: 'BLACK_FRIDAY',
|
||||
eventVariant: 'CAMPAIGN_WINDOW' as const,
|
||||
eventTitleFa: 'بلکفرایدی',
|
||||
date: '2026-11-27T00:00:00Z',
|
||||
endDate: '2026-11-29T23:59:59Z',
|
||||
eventType: 'COMMERCIAL',
|
||||
officialHoliday: false,
|
||||
merchantCategories: [],
|
||||
behavioralChannels: [],
|
||||
merchantCategories: ['فروشگاه آنلاین'],
|
||||
behavioralChannels: ['ONLINE'],
|
||||
insightBasis: 'HISTORICAL' as const,
|
||||
historicalEvidence: {
|
||||
priorStartDate: '2025-11-28T00:00:00Z',
|
||||
priorEndDate: '2025-11-30T23:59:59Z',
|
||||
observationCount: 6,
|
||||
paidSessionUpliftBps: 2450,
|
||||
amountUpliftBps: 3180,
|
||||
materialWin: true,
|
||||
materialDecline: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
evidenceId: 'event-4',
|
||||
eventId: 'event-4',
|
||||
evidenceId: 'event-yalda',
|
||||
eventId: 'yalda-2026',
|
||||
occasionKey: 'YALDA',
|
||||
eventVariant: 'PRIMARY' as const,
|
||||
eventTitleFa: 'شب یلدا',
|
||||
date: '2026-12-21T00:00:00Z',
|
||||
endDate: '2026-12-21T23:59:59Z',
|
||||
eventType: 'CULTURAL',
|
||||
officialHoliday: false,
|
||||
merchantCategories: ['فروشگاه آنلاین'],
|
||||
behavioralChannels: ['ONLINE'],
|
||||
insightBasis: 'BUSINESS_PROFILE' as const,
|
||||
profileEvidence: {
|
||||
matchedSignals: ['فروش آنلاین', 'محصول هدیهپذیر'],
|
||||
historyUnavailableReason: 'INSUFFICIENT_HISTORY' as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
evidenceId: 'event-cyber-monday',
|
||||
eventId: 'cyber-monday-2026',
|
||||
occasionKey: 'CYBER_MONDAY',
|
||||
eventVariant: 'OPERATIONAL_WINDOW' as const,
|
||||
eventTitleFa: 'سایبرماندی',
|
||||
date: '2026-11-30T00:00:00Z',
|
||||
endDate: '2026-11-30T23:59:59Z',
|
||||
eventType: 'COMMERCIAL',
|
||||
officialHoliday: false,
|
||||
merchantCategories: [],
|
||||
behavioralChannels: [],
|
||||
merchantCategories: ['فروشگاه آنلاین'],
|
||||
behavioralChannels: ['ONLINE'],
|
||||
insightBasis: 'HISTORICAL' as const,
|
||||
historicalEvidence: {
|
||||
priorStartDate: '2025-12-01T00:00:00Z',
|
||||
priorEndDate: '2025-12-01T23:59:59Z',
|
||||
observationCount: 5,
|
||||
paidSessionUpliftBps: -1340,
|
||||
amountUpliftBps: -2080,
|
||||
materialWin: false,
|
||||
materialDecline: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const recommendations = [
|
||||
{
|
||||
evidenceId: 'event-black-friday',
|
||||
priorityPercent: 52,
|
||||
stance: 'PROMOTE' as const,
|
||||
insightBasis: 'HISTORICAL' as const,
|
||||
relevanceReason: 'فروش موفق پارسال نشان میدهد پیشنهاد زودهنگام میتواند دوباره تقاضا را جذب کند.',
|
||||
},
|
||||
{
|
||||
evidenceId: 'event-yalda',
|
||||
priorityPercent: 31,
|
||||
stance: 'PREPARE' as const,
|
||||
insightBasis: 'BUSINESS_PROFILE' as const,
|
||||
relevanceReason: 'محصولات هدیهپذیر شما با خریدهای مناسبتی شب یلدا همخوانی دارند.',
|
||||
},
|
||||
{
|
||||
evidenceId: 'event-cyber-monday',
|
||||
priorityPercent: 17,
|
||||
stance: 'CAUTION' as const,
|
||||
insightBasis: 'HISTORICAL' as const,
|
||||
relevanceReason: 'افت مبلغ فروش در دوره قبل، اجرای محدود و کنترل موجودی را منطقیتر میکند.',
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('GrowthEventImpactChart', () => {
|
||||
it('uses AI rank order and shows each grounded relevance reason', () => {
|
||||
it('renders a ranked three-column priority histogram whose percentages total 100', () => {
|
||||
render(GrowthEventImpactChart, {
|
||||
props: { events, recommendations, now: new Date('2026-08-21T00:00:00Z') },
|
||||
});
|
||||
|
||||
const chart = screen.getByRole('figure', { name: 'نمودار اولویت برنامهریزی سه رویداد پیش رو' });
|
||||
const bars = within(chart).getAllByTestId('priority-bar');
|
||||
expect(bars).toHaveLength(3);
|
||||
expect(bars[0].getAttribute('style')).toContain('--priority: 52');
|
||||
expect(bars[1].getAttribute('style')).toContain('--priority: 31');
|
||||
expect(bars[2].getAttribute('style')).toContain('--priority: 17');
|
||||
expect(within(chart).getByText('۵۲٪')).not.toBeNull();
|
||||
expect(within(chart).getByText('رتبه ۱')).not.toBeNull();
|
||||
expect(within(chart).getByText('رتبه ۲')).not.toBeNull();
|
||||
expect(within(chart).getByText('رتبه ۳')).not.toBeNull();
|
||||
expect(within(chart).getByText(/جمع اولویتها: ۱۰۰٪/)).not.toBeNull();
|
||||
expect(screen.getByText(/این درصد، سهم اولویت برای برنامهریزی است؛ نه احتمال رشد فروش/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows deterministic historical evidence and a clear positive stance', () => {
|
||||
render(GrowthEventImpactChart, {
|
||||
props: { events, recommendations, now: new Date('2026-08-21T00:00:00Z') },
|
||||
});
|
||||
|
||||
const card = screen.getByRole('article', { name: 'رتبه ۱: بلکفرایدی' });
|
||||
expect(card.textContent).toContain('بر پایه تجربه شما');
|
||||
expect(card.textContent).toContain('پیشنهاد: ترویج');
|
||||
expect(card.textContent).toContain('۶ بازه مقایسهشده');
|
||||
expect(card.textContent).toContain('تعداد پرداخت موفق ۲۴٫۵٪ افزایش');
|
||||
expect(card.textContent).toContain('مبلغ فروش ۳۱٫۸٪ افزایش');
|
||||
expect(card.textContent).toContain(recommendations[0].relevanceReason);
|
||||
expect(card.textContent).toContain('دوره مشابه قبلی');
|
||||
});
|
||||
|
||||
it('uses onboarding profile signals when matching historical data is unavailable', () => {
|
||||
render(GrowthEventImpactChart, {
|
||||
props: { events, recommendations, now: new Date('2026-08-21T00:00:00Z') },
|
||||
});
|
||||
|
||||
const card = screen.getByRole('article', { name: 'رتبه ۲: شب یلدا' });
|
||||
expect(card.textContent).toContain('بر پایه شناخت کسبوکار');
|
||||
expect(card.textContent).toContain('پیشنهاد: آمادهسازی');
|
||||
expect(card.textContent).toContain('فروش آنلاین');
|
||||
expect(card.textContent).toContain('محصول هدیهپذیر');
|
||||
expect(card.textContent).toContain('هنوز سابقهٔ کافی برای این مناسبت ثبت نشده است.');
|
||||
expect(card.textContent).not.toContain('INSUFFICIENT_HISTORY');
|
||||
expect(card.textContent).toContain(recommendations[1].relevanceReason);
|
||||
});
|
||||
|
||||
it('uses truthful general profile copy when no matched signal is available', () => {
|
||||
const profileEvent = events[1];
|
||||
if (profileEvent.insightBasis !== 'BUSINESS_PROFILE') throw new Error('Expected profile-backed fixture');
|
||||
|
||||
render(GrowthEventImpactChart, {
|
||||
props: {
|
||||
events: [
|
||||
{ ...profileEvent, profileEvidence: { ...profileEvent.profileEvidence, matchedSignals: [] } },
|
||||
],
|
||||
now: new Date('2026-08-21T00:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
const card = screen.getByRole('article', { name: 'رتبه ۱: شب یلدا' });
|
||||
expect(card.textContent).toContain('شناخت کلی ثبتشده از کسبوکار شما');
|
||||
expect(screen.queryByRole('list', { name: 'نشانههای مرتبط کسبوکار' })).toBeNull();
|
||||
});
|
||||
|
||||
it('states a caution recommendation when prior performance materially declined', () => {
|
||||
render(GrowthEventImpactChart, {
|
||||
props: { events, recommendations, now: new Date('2026-08-21T00:00:00Z') },
|
||||
});
|
||||
|
||||
const card = screen.getByRole('article', { name: 'رتبه ۳: سایبرماندی' });
|
||||
expect(card.textContent).toContain('پیشنهاد: با احتیاط');
|
||||
expect(card.textContent).toContain('تعداد پرداخت موفق ۱۳٫۴٪ کاهش');
|
||||
expect(card.textContent).toContain('مبلغ فروش ۲۰٫۸٪ کاهش');
|
||||
});
|
||||
|
||||
it('keeps a grounded 100-percent fallback when written recommendations are unavailable', () => {
|
||||
render(GrowthEventImpactChart, {
|
||||
props: { events, now: new Date('2026-08-21T00:00:00Z') },
|
||||
});
|
||||
|
||||
const chart = screen.getByRole('figure', { name: 'نمودار اولویت برنامهریزی سه رویداد پیش رو' });
|
||||
expect(within(chart).getByText(/جمع اولویتها: ۱۰۰٪/)).not.toBeNull();
|
||||
expect(within(chart).getAllByTestId('priority-bar')[0].getAttribute('style')).toContain('--priority: 34');
|
||||
expect(screen.getByRole('article', { name: 'رتبه ۱: بلکفرایدی' }).textContent).toContain(
|
||||
'رفتار پرداخت در دوره مشابه قبلی',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits past events without leaving an inaccessible chart', () => {
|
||||
render(GrowthEventImpactChart, {
|
||||
props: {
|
||||
events: [{ ...events[0], date: '2026-08-20T00:00:00Z', endDate: '2026-08-20T16:00:00Z' }],
|
||||
recommendations: [recommendations[0]],
|
||||
now: new Date('2026-08-21T00:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('figure')).toBeNull();
|
||||
expect(screen.getByRole('status').textContent).toContain('رویداد مرتبطی در تقویم بررسیشده پیدا نشد');
|
||||
});
|
||||
|
||||
it('keeps an event visible through its inclusive Tehran end date', () => {
|
||||
render(GrowthEventImpactChart, {
|
||||
props: {
|
||||
events: [{ ...events[0], date: '2026-08-21T00:00:00Z', endDate: '2026-08-21T00:00:00Z' }],
|
||||
now: new Date('2026-08-21T15:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole('figure', { name: 'نمودار اولویت برنامهریزی ۱ رویداد پیش رو' })).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1, '۱', [100]],
|
||||
[2, '۲', [50, 50]],
|
||||
])('truthfully normalizes %i available candidate(s)', (count, persianCount, expectedShares) => {
|
||||
render(GrowthEventImpactChart, {
|
||||
props: { events: events.slice(0, count), now: new Date('2026-08-21T00:00:00Z') },
|
||||
});
|
||||
|
||||
const chart = screen.getByRole('figure', {
|
||||
name: `نمودار اولویت برنامهریزی ${persianCount} رویداد پیش رو`,
|
||||
});
|
||||
expect(within(chart).getAllByTestId('priority-bar')).toHaveLength(count);
|
||||
expectedShares.forEach((share, index) => {
|
||||
expect(within(chart).getAllByTestId('priority-bar')[index].getAttribute('style')).toContain(
|
||||
`--priority: ${share}`,
|
||||
);
|
||||
});
|
||||
expect(screen.getByText(new RegExp(`تنها ${persianCount} رویداد مرتبط`))).not.toBeNull();
|
||||
expect(screen.queryByText(/جمع سه اولویت/)).toBeNull();
|
||||
expect(within(chart).getByText(/جمع اولویتها: ۱۰۰٪/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps deterministic evidence authoritative when recommendation basis disagrees', () => {
|
||||
render(GrowthEventImpactChart, {
|
||||
props: {
|
||||
events,
|
||||
recommendations: [
|
||||
{ evidenceId: 'event-1', relevanceReason: 'زمان مناسبی برای آمادهسازی پیشنهادهای فروش است.' },
|
||||
{ evidenceId: 'event-3', relevanceReason: 'این رویداد با فروش آنلاین کسبوکار مرتبط است.' },
|
||||
{ evidenceId: 'event-2', relevanceReason: 'این مناسبت برای برنامهریزی پیامرسانی مناسب است.' },
|
||||
{ ...recommendations[0], insightBasis: 'BUSINESS_PROFILE' as const },
|
||||
recommendations[1],
|
||||
recommendations[2],
|
||||
],
|
||||
now: new Date('2026-08-21T00:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
const cards = screen.getAllByRole('listitem');
|
||||
expect(cards).toHaveLength(3);
|
||||
expect(cards[0]).toHaveTextContent('نوروز');
|
||||
expect(cards[0]).toHaveTextContent('زمان مناسبی برای آمادهسازی پیشنهادهای فروش است.');
|
||||
expect(cards[1]).toHaveTextContent('بلکفرایدی');
|
||||
const card = screen.getByRole('article', { name: 'رتبه ۱: بلکفرایدی' });
|
||||
expect(card.textContent).toContain('بر پایه تجربه شما');
|
||||
expect(card.textContent).toContain('دوره مشابه قبلی');
|
||||
});
|
||||
|
||||
it('falls back to the first three upcoming reviewed events when AI is unavailable', () => {
|
||||
render(GrowthEventImpactChart, { props: { events, now: new Date('2026-08-21T00:00:00Z') } });
|
||||
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
||||
expect(screen.getByText('بلکفرایدی')).not.toBeNull();
|
||||
expect(
|
||||
screen.getAllByText('این مناسبت در تقویم بررسیشده بهعنوان رویدادی مرتبط با کسبوکار ثبت شده است.'),
|
||||
).toHaveLength(3);
|
||||
expect(screen.queryByText('نوروز')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not render events that are already in the past', () => {
|
||||
it('shows a visible partial notice when AI prose fell back', () => {
|
||||
render(GrowthEventImpactChart, {
|
||||
props: {
|
||||
events: [
|
||||
{ ...events[0], evidenceId: 'past', eventTitleFa: 'رویداد گذشته', date: '2026-08-20T00:00:00Z' },
|
||||
events[2],
|
||||
],
|
||||
now: new Date('2026-08-21T00:00:00Z'),
|
||||
},
|
||||
props: { events, recommendations, isPartial: true, now: new Date('2026-08-21T00:00:00Z') },
|
||||
});
|
||||
|
||||
expect(screen.queryByText('رویداد گذشته')).toBeNull();
|
||||
expect(screen.getByText('بلکفرایدی')).not.toBeNull();
|
||||
expect(screen.getByRole('status', { name: 'تحلیل تکمیلی موقت' }).textContent).toContain(
|
||||
'متن تفسیری هوشمند این نوبت آماده نشد',
|
||||
);
|
||||
});
|
||||
|
||||
it('explains a sparse partial result without blaming AI availability', () => {
|
||||
render(GrowthEventImpactChart, {
|
||||
props: { events: events.slice(0, 1), isPartial: true, now: new Date('2026-08-21T00:00:00Z') },
|
||||
});
|
||||
|
||||
const notice = screen.getByRole('status', { name: 'تحلیل تکمیلی موقت' });
|
||||
expect(notice.textContent).toContain('رویدادهای مرتبط کافی برای رتبهبندی کامل پیدا نشد');
|
||||
expect(notice.textContent).not.toContain('متن تفسیری هوشمند این نوبت آماده نشد');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -823,13 +823,16 @@
|
||||
>
|
||||
</section>{/if}
|
||||
<section aria-labelledby="upcoming-events-title" class="space-y-3">
|
||||
<Card class="overflow-hidden rounded-xl">
|
||||
<Card class="overflow-hidden rounded-lg">
|
||||
<CardHeader class="border-b">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle id="upcoming-events-title" class="text-lg font-black">سه رویداد پیش رو</CardTitle>
|
||||
<CardTitle id="upcoming-events-title" class="text-lg font-black"
|
||||
>اولویت رویدادهای پیش رو</CardTitle
|
||||
>
|
||||
<p class="text-muted-foreground mt-1 max-w-3xl text-sm leading-6">
|
||||
سه مناسبت مرتبط با فعالیت شما، بر پایه تقویم بررسیشده و اولویتبندی هوشمند، نمایش داده میشوند.
|
||||
سه مناسبت نزدیک، بر پایه تجربه دورههای مشابه یا نشانههای ثبتشده در پروفایل کسبوکار شما
|
||||
رتبهبندی شدهاند.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -856,6 +859,7 @@
|
||||
<GrowthEventImpactChart
|
||||
events={legacyForecast.deterministicEvidence.events}
|
||||
recommendations={legacyForecast.aiCopy?.eventRecommendations}
|
||||
isPartial={legacyForecast.status === 'PARTIAL'}
|
||||
/>
|
||||
{:else if forecastRefreshing}
|
||||
<div class="flex min-h-64 items-center justify-center gap-3" role="status">
|
||||
|
||||
Reference in New Issue
Block a user