387 lines
16 KiB
Svelte
387 lines
16 KiB
Svelte
<script lang="ts">
|
|
import type { components } from '$lib/api/schema';
|
|
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 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.insightBasis !== 'BUSINESS_PROFILE' ||
|
|
!event.profileEvidence?.matchedSignals?.length
|
|
) {
|
|
return 'این مناسبت با توجه به شناخت کلی ثبتشده از کسبوکار شما برای بررسی بیشتر انتخاب شده است.';
|
|
}
|
|
return 'ویژگیهای این مناسبت با نشانههای ثبتشده در پروفایل کسبوکار شما همخوانی دارد.';
|
|
}
|
|
|
|
const visibleEvents = $derived.by(() => {
|
|
const todayKey = tehranCalendarKey(now);
|
|
const upcoming = events.filter((event) => {
|
|
const endKey = tehranCalendarKey(event.endDate ?? event.date);
|
|
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="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'
|
|
? 'بر پایه تجربه شما'
|
|
: event.insightBasis === 'BUSINESS_PROFILE'
|
|
? 'بر پایه شناخت کسبوکار'
|
|
: 'بر پایه تقویم رویدادها'}
|
|
</span>
|
|
</div>
|
|
|
|
<p class="text-muted-foreground mt-2 text-xs">
|
|
زمان رویداد: {dateRange(event.date, event.endDate ?? event.date)}
|
|
</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">
|
|
<p class="font-bold">رویداد مرتبطی در تقویم بررسیشده پیدا نشد</p>
|
|
<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>
|