From f3f8a0bb996ecdf36b618798a25038d2c9c4ae28 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sat, 22 Aug 2026 17:25:23 +0800 Subject: [PATCH] fix: improve transaction status trend inspection --- .../components/TransactionStatusChart.svelte | 29 -- .../components/TransactionStatusChart.test.ts | 6 +- .../TransactionStatusTrendChart.svelte | 347 +++++++++++++++--- .../TransactionStatusTrendChart.test.ts | 25 +- 4 files changed, 326 insertions(+), 81 deletions(-) diff --git a/src/lib/components/TransactionStatusChart.svelte b/src/lib/components/TransactionStatusChart.svelte index 134a101..f2f3152 100644 --- a/src/lib/components/TransactionStatusChart.svelte +++ b/src/lib/components/TransactionStatusChart.svelte @@ -75,33 +75,4 @@ {/each} - -
- - جدول داده‌های نمودار وضعیت - -
- - - - - - - {#each ordered as entry} - - - - - - {/each} - -
تعداد و سهم تراکنش‌ها به تفکیک وضعیت
وضعیتتعدادسهم از کل
{statusLabels[entry.status]}{formatValue(entry.count)}{formatPercent(entry.sharePercent)}
-
-
diff --git a/src/lib/components/TransactionStatusChart.test.ts b/src/lib/components/TransactionStatusChart.test.ts index 9c0a8d2..ad30f62 100644 --- a/src/lib/components/TransactionStatusChart.test.ts +++ b/src/lib/components/TransactionStatusChart.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import TransactionStatusChart from './TransactionStatusChart.svelte'; describe('TransactionStatusChart', () => { - it('renders canonical labels, exact accessible values, and a data-table alternative', () => { + it('renders canonical labels and exact accessible values without a duplicate data table', () => { render(TransactionStatusChart, { data: [ { status: 'Verified', count: 60, sharePercent: '60' }, @@ -16,7 +16,7 @@ describe('TransactionStatusChart', () => { expect(screen.getByRole('button', { name: /پرداخت‌شده، در انتظار تأیید، ۱۰ تراکنش، ۱۰٪/ })).toBeTruthy(); expect(screen.getByRole('button', { name: /تأیید نهایی، ۶۰ تراکنش، ۶۰٪/ })).toBeTruthy(); - expect(screen.getByText('جدول داده‌های نمودار وضعیت')).toBeTruthy(); - expect(screen.getByRole('table')).toBeTruthy(); + expect(screen.queryByText('جدول داده‌های نمودار وضعیت')).toBeNull(); + expect(screen.queryByRole('table')).toBeNull(); }); }); diff --git a/src/lib/components/TransactionStatusTrendChart.svelte b/src/lib/components/TransactionStatusTrendChart.svelte index 6b78d7a..22e8af0 100644 --- a/src/lib/components/TransactionStatusTrendChart.svelte +++ b/src/lib/components/TransactionStatusTrendChart.svelte @@ -7,14 +7,18 @@ type PeriodType = components['schemas']['PeriodType']; let { data, periodType }: { data: Point[]; periodType: PeriodType } = $props(); - let active = $state<{ bucket: number; status: TransactionStatus } | null>(null); + let activeBucket = $state(null); const width = 760; const height = 300; const pad = { top: 24, right: 24, bottom: 54, left: 62 }; const plotWidth = width - pad.left - pad.right; const plotHeight = height - pad.top - pad.bottom; - const hour = new Intl.NumberFormat('fa-IR', { minimumIntegerDigits: 2, maximumFractionDigits: 0, useGrouping: false }); + const hour = new Intl.NumberFormat('fa-IR', { + minimumIntegerDigits: 2, + maximumFractionDigits: 0, + useGrouping: false, + }); const date = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { month: 'short', day: 'numeric' }); const statusColors: Record = { @@ -23,6 +27,12 @@ Failed: '#e11d48', Reversed: '#d97706', }; + const statusLinePatterns: Record = { + Paid: {}, + Verified: { dash: '13 5', offset: 0 }, + Failed: { dash: '6 5', offset: 2 }, + Reversed: { dash: '2 6', offset: 4 }, + }; function countFor(point: Point, status: TransactionStatus) { if (status === 'Paid') return point.paidSessions; @@ -31,23 +41,44 @@ return point.reversedSessions; } - const maxCount = $derived(Math.max(1, ...data.flatMap((point) => statusOrder.map((status) => countFor(point, status))))); - const series = $derived(statusOrder.map((status) => ({ - status, - points: data.map((point, bucket) => ({ - bucket, - point, - count: countFor(point, status), - x: pad.left + (bucket * plotWidth) / Math.max(1, data.length - 1), - y: pad.top + plotHeight - (countFor(point, status) / maxCount) * plotHeight, + const maxCount = $derived( + Math.max(1, ...data.flatMap((point) => statusOrder.map((status) => countFor(point, status)))), + ); + const series = $derived( + statusOrder.map((status) => ({ + status, + points: data.map((point, bucket) => ({ + bucket, + point, + count: countFor(point, status), + x: pad.left + (bucket * plotWidth) / Math.max(1, data.length - 1), + y: pad.top + plotHeight - (countFor(point, status) / maxCount) * plotHeight, + })), })), - }))); + ); const xTickIndexes = $derived.by(() => { const tickCount = Math.min(6, data.length); if (tickCount <= 1) return data.length ? [0] : []; - return [...new Set(Array.from({ length: tickCount }, (_, index) => Math.round((index * (data.length - 1)) / (tickCount - 1))))]; + return [ + ...new Set( + Array.from({ length: tickCount }, (_, index) => + Math.round((index * (data.length - 1)) / (tickCount - 1)), + ), + ), + ]; }); - const activePoint = $derived(active === null ? null : series.find(({ status }) => status === active.status)?.points[active.bucket] ?? null); + const hoverZones = $derived( + data.map((point, bucket) => { + const x = series[0]?.points[bucket]?.x ?? pad.left; + const previousX = series[0]?.points[bucket - 1]?.x; + const nextX = series[0]?.points[bucket + 1]?.x; + const start = previousX === undefined ? pad.left : (previousX + x) / 2; + const end = nextX === undefined ? width - pad.right : (x + nextX) / 2; + return { bucket, point, x, start, width: end - start }; + }), + ); + const activePoint = $derived(activeBucket === null ? null : (data[activeBucket] ?? null)); + const activeX = $derived(activeBucket === null ? null : (series[0]?.points[activeBucket]?.x ?? null)); function bucketLabel(point: Point) { if (periodType === 'DAY' && point.localHour !== undefined) return `${hour.format(point.localHour)}:۰۰`; @@ -62,53 +93,210 @@ return points.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.y}`).join(' '); } - function tooltipStyle(point: NonNullable) { - const horizontal = (point.x / width) * 100; - const vertical = (point.y / height) * 100; - const below = vertical < 35; - return `left:clamp(6rem,${horizontal}%,calc(100% - 6rem));top:${below ? vertical + 5 : vertical - 4}%;transform:translate(-50%,${below ? '0' : '-100%'});`; + function collisionRadius(point: Point, status: TransactionStatus, bucket: number) { + const count = countFor(point, status); + const collisions = statusOrder.filter((candidate) => countFor(point, candidate) === count); + const ring = collisions.indexOf(status); + const base = activeBucket === bucket ? 5.5 : 3.25; + return base + (collisions.length - ring - 1) * 2.75; + } + + function tooltipStyle(x: number) { + return `left:clamp(7rem,${(x / width) * 100}%,calc(100% - 7rem));top:0.75rem;transform:translateX(-50%);`; + } + + function bucketAria(point: Point) { + const values = statusOrder + .map((status) => { + const count = countFor(point, status); + return `${statusLabels[status]} ${formatValue(count)} تراکنش ${formatPercent(share(point, count))}`; + }) + .join('، '); + return `${bucketLabel(point)}، ${values}`; }
{#each statusOrder as status} - {statusLabels[status]} + {statusLabels[status]} {/each}
-
-
+
+