fix: improve transaction status trend inspection
This commit is contained in:
@@ -75,33 +75,4 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="border-border rounded-md border">
|
|
||||||
<summary
|
|
||||||
class="focus-visible:ring-ring cursor-pointer rounded-md px-4 py-3 text-sm font-semibold focus-visible:ring-2"
|
|
||||||
>
|
|
||||||
جدول دادههای نمودار وضعیت
|
|
||||||
</summary>
|
|
||||||
<div class="overflow-x-auto border-t">
|
|
||||||
<table class="w-full min-w-96 text-sm">
|
|
||||||
<caption class="sr-only">تعداد و سهم تراکنشها به تفکیک وضعیت</caption>
|
|
||||||
<thead class="bg-muted/50">
|
|
||||||
<tr
|
|
||||||
><th class="p-3 text-right">وضعیت</th><th class="p-3 text-right">تعداد</th><th
|
|
||||||
class="p-3 text-right">سهم از کل</th
|
|
||||||
></tr
|
|
||||||
>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each ordered as entry}
|
|
||||||
<tr class="border-t">
|
|
||||||
<th class="p-3 text-right font-medium">{statusLabels[entry.status]}</th>
|
|
||||||
<td class="p-3 tabular-nums">{formatValue(entry.count)}</td>
|
|
||||||
<td class="p-3 tabular-nums">{formatPercent(entry.sharePercent)}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import TransactionStatusChart from './TransactionStatusChart.svelte';
|
import TransactionStatusChart from './TransactionStatusChart.svelte';
|
||||||
|
|
||||||
describe('TransactionStatusChart', () => {
|
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, {
|
render(TransactionStatusChart, {
|
||||||
data: [
|
data: [
|
||||||
{ status: 'Verified', count: 60, sharePercent: '60' },
|
{ 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.getByRole('button', { name: /تأیید نهایی، ۶۰ تراکنش، ۶۰٪/ })).toBeTruthy();
|
expect(screen.getByRole('button', { name: /تأیید نهایی، ۶۰ تراکنش، ۶۰٪/ })).toBeTruthy();
|
||||||
expect(screen.getByText('جدول دادههای نمودار وضعیت')).toBeTruthy();
|
expect(screen.queryByText('جدول دادههای نمودار وضعیت')).toBeNull();
|
||||||
expect(screen.getByRole('table')).toBeTruthy();
|
expect(screen.queryByRole('table')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,14 +7,18 @@
|
|||||||
type PeriodType = components['schemas']['PeriodType'];
|
type PeriodType = components['schemas']['PeriodType'];
|
||||||
|
|
||||||
let { data, periodType }: { data: Point[]; periodType: PeriodType } = $props();
|
let { data, periodType }: { data: Point[]; periodType: PeriodType } = $props();
|
||||||
let active = $state<{ bucket: number; status: TransactionStatus } | null>(null);
|
let activeBucket = $state<number | null>(null);
|
||||||
|
|
||||||
const width = 760;
|
const width = 760;
|
||||||
const height = 300;
|
const height = 300;
|
||||||
const pad = { top: 24, right: 24, bottom: 54, left: 62 };
|
const pad = { top: 24, right: 24, bottom: 54, left: 62 };
|
||||||
const plotWidth = width - pad.left - pad.right;
|
const plotWidth = width - pad.left - pad.right;
|
||||||
const plotHeight = height - pad.top - pad.bottom;
|
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 date = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { month: 'short', day: 'numeric' });
|
||||||
|
|
||||||
const statusColors: Record<TransactionStatus, string> = {
|
const statusColors: Record<TransactionStatus, string> = {
|
||||||
@@ -23,6 +27,12 @@
|
|||||||
Failed: '#e11d48',
|
Failed: '#e11d48',
|
||||||
Reversed: '#d97706',
|
Reversed: '#d97706',
|
||||||
};
|
};
|
||||||
|
const statusLinePatterns: Record<TransactionStatus, { dash?: string; offset?: number }> = {
|
||||||
|
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) {
|
function countFor(point: Point, status: TransactionStatus) {
|
||||||
if (status === 'Paid') return point.paidSessions;
|
if (status === 'Paid') return point.paidSessions;
|
||||||
@@ -31,23 +41,44 @@
|
|||||||
return point.reversedSessions;
|
return point.reversedSessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxCount = $derived(Math.max(1, ...data.flatMap((point) => statusOrder.map((status) => countFor(point, status)))));
|
const maxCount = $derived(
|
||||||
const series = $derived(statusOrder.map((status) => ({
|
Math.max(1, ...data.flatMap((point) => statusOrder.map((status) => countFor(point, status)))),
|
||||||
status,
|
);
|
||||||
points: data.map((point, bucket) => ({
|
const series = $derived(
|
||||||
bucket,
|
statusOrder.map((status) => ({
|
||||||
point,
|
status,
|
||||||
count: countFor(point, status),
|
points: data.map((point, bucket) => ({
|
||||||
x: pad.left + (bucket * plotWidth) / Math.max(1, data.length - 1),
|
bucket,
|
||||||
y: pad.top + plotHeight - (countFor(point, status) / maxCount) * plotHeight,
|
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 xTickIndexes = $derived.by(() => {
|
||||||
const tickCount = Math.min(6, data.length);
|
const tickCount = Math.min(6, data.length);
|
||||||
if (tickCount <= 1) return data.length ? [0] : [];
|
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) {
|
function bucketLabel(point: Point) {
|
||||||
if (periodType === 'DAY' && point.localHour !== undefined) return `${hour.format(point.localHour)}:۰۰`;
|
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(' ');
|
return points.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.y}`).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function tooltipStyle(point: NonNullable<typeof activePoint>) {
|
function collisionRadius(point: Point, status: TransactionStatus, bucket: number) {
|
||||||
const horizontal = (point.x / width) * 100;
|
const count = countFor(point, status);
|
||||||
const vertical = (point.y / height) * 100;
|
const collisions = statusOrder.filter((candidate) => countFor(point, candidate) === count);
|
||||||
const below = vertical < 35;
|
const ring = collisions.indexOf(status);
|
||||||
return `left:clamp(6rem,${horizontal}%,calc(100% - 6rem));top:${below ? vertical + 5 : vertical - 4}%;transform:translate(-50%,${below ? '0' : '-100%'});`;
|
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}`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<div class="flex flex-wrap gap-x-5 gap-y-2 text-xs" aria-label="راهنمای رنگ وضعیتها">
|
<div class="flex flex-wrap gap-x-5 gap-y-2 text-xs" aria-label="راهنمای رنگ وضعیتها">
|
||||||
{#each statusOrder as status}
|
{#each statusOrder as status}
|
||||||
<span class="inline-flex items-center gap-2"><span class="size-2.5 rounded-full" style={`background:${statusColors[status]}`} aria-hidden="true"></span>{statusLabels[status]}</span>
|
<span class="inline-flex items-center gap-2"
|
||||||
|
><span class="size-2.5 rounded-full" style={`background:${statusColors[status]}`} aria-hidden="true"
|
||||||
|
></span>{statusLabels[status]}</span
|
||||||
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (the horizontally scrollable chart must be keyboard reachable) -->
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (the horizontally scrollable chart must be keyboard reachable) -->
|
||||||
<div class="w-full overflow-x-auto overscroll-x-contain" dir="ltr" role="region" aria-label="نمودار خطی روند وضعیت تراکنشها، قابل پیمایش افقی" tabindex="0">
|
<div
|
||||||
<div class="relative min-h-[310px] min-w-[760px] md:min-w-0">
|
class="w-full overflow-x-auto overscroll-x-contain"
|
||||||
|
dir="ltr"
|
||||||
|
role="region"
|
||||||
|
aria-label="نمودار خطی روند وضعیت تراکنشها، قابل پیمایش افقی"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative min-h-[310px] min-w-[760px] md:min-w-0"
|
||||||
|
role="presentation"
|
||||||
|
onmouseleave={() => (activeBucket = null)}
|
||||||
|
>
|
||||||
{#if data.length === 0}
|
{#if data.length === 0}
|
||||||
<div class="text-muted-foreground grid h-[280px] place-items-center text-sm" role="status">دادهای برای این بازه ثبت نشده است.</div>
|
<div class="text-muted-foreground grid h-[280px] place-items-center text-sm" role="status">
|
||||||
|
دادهای برای این بازه ثبت نشده است.
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<svg viewBox={`0 0 ${width} ${height}`} class="h-auto min-h-[300px] w-full overflow-visible" role="img" aria-label="روند زمانی تعداد تراکنشها در چهار وضعیت">
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
class="h-auto min-h-[300px] w-full overflow-visible"
|
||||||
|
role="img"
|
||||||
|
aria-label="روند زمانی تعداد تراکنشها در چهار وضعیت"
|
||||||
|
>
|
||||||
{#each [0, 1, 2, 3, 4] as row}
|
{#each [0, 1, 2, 3, 4] as row}
|
||||||
{@const y = pad.top + (row * plotHeight) / 4}
|
{@const y = pad.top + (row * plotHeight) / 4}
|
||||||
<line x1={pad.left} x2={width - pad.right} y1={y} y2={y} stroke="var(--border)" stroke-dasharray="4 6" />
|
<line
|
||||||
<text x={pad.left - 10} y={y + 4} fill="var(--muted-foreground)" font-size="10" text-anchor="end">{formatValue(Math.round(maxCount * (1 - row / 4)))}</text>
|
x1={pad.left}
|
||||||
|
x2={width - pad.right}
|
||||||
|
y1={y}
|
||||||
|
y2={y}
|
||||||
|
stroke="var(--border)"
|
||||||
|
stroke-dasharray="4 6"
|
||||||
|
/>
|
||||||
|
<text x={pad.left - 10} y={y + 4} fill="var(--muted-foreground)" font-size="10" text-anchor="end"
|
||||||
|
>{formatValue(Math.round(maxCount * (1 - row / 4)))}</text
|
||||||
|
>
|
||||||
{/each}
|
{/each}
|
||||||
<line x1={pad.left} x2={pad.left} y1={pad.top} y2={height - pad.bottom} stroke="var(--border)" />
|
<line x1={pad.left} x2={pad.left} y1={pad.top} y2={height - pad.bottom} stroke="var(--border)" />
|
||||||
<line x1={pad.left} x2={width - pad.right} y1={height - pad.bottom} y2={height - pad.bottom} stroke="var(--border)" />
|
<line
|
||||||
|
x1={pad.left}
|
||||||
|
x2={width - pad.right}
|
||||||
|
y1={height - pad.bottom}
|
||||||
|
y2={height - pad.bottom}
|
||||||
|
stroke="var(--border)"
|
||||||
|
/>
|
||||||
{#each xTickIndexes as tickIndex}
|
{#each xTickIndexes as tickIndex}
|
||||||
{@const tick = series[0]?.points[tickIndex]}
|
{@const tick = series[0]?.points[tickIndex]}
|
||||||
{#if tick}<text x={tick.x} y={height - pad.bottom + 20} fill="var(--muted-foreground)" font-size="9" text-anchor="middle" direction="rtl">{bucketLabel(tick.point)}</text>{/if}
|
{#if tick}<text
|
||||||
|
x={tick.x}
|
||||||
|
y={height - pad.bottom + 20}
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
font-size="9"
|
||||||
|
text-anchor="middle"
|
||||||
|
direction="rtl">{bucketLabel(tick.point)}</text
|
||||||
|
>{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<text x={(pad.left + width - pad.right) / 2} y={height - 5} fill="var(--muted-foreground)" font-size="10" text-anchor="middle" direction="rtl">{periodType === 'DAY' ? 'ساعت' : 'زمان'}</text>
|
<text
|
||||||
<text x={-((pad.top + height - pad.bottom) / 2)} y="13" fill="var(--muted-foreground)" font-size="10" text-anchor="middle" transform="rotate(-90)" direction="rtl">تعداد تراکنش</text>
|
x={(pad.left + width - pad.right) / 2}
|
||||||
|
y={height - 5}
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
font-size="10"
|
||||||
|
text-anchor="middle"
|
||||||
|
direction="rtl">{periodType === 'DAY' ? 'ساعت' : 'زمان'}</text
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
x={-((pad.top + height - pad.bottom) / 2)}
|
||||||
|
y="13"
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
font-size="10"
|
||||||
|
text-anchor="middle"
|
||||||
|
transform="rotate(-90)"
|
||||||
|
direction="rtl">تعداد تراکنش</text
|
||||||
|
>
|
||||||
|
|
||||||
{#each series as statusSeries}
|
{#each series as statusSeries}
|
||||||
<path d={linePath(statusSeries.points)} fill="none" stroke={statusColors[statusSeries.status]} stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="status-line" />
|
{@const pattern = statusLinePatterns[statusSeries.status]}
|
||||||
{#each statusSeries.points as point}
|
<path
|
||||||
<circle cx={point.x} cy={point.y} r={active?.bucket === point.bucket && active.status === statusSeries.status ? 5.5 : 3.25} fill="var(--card)" stroke={statusColors[statusSeries.status]} stroke-width="2.5" tabindex="0" role="button" aria-label={`${bucketLabel(point.point)}، ${statusLabels[statusSeries.status]}، ${formatValue(point.count)} تراکنش، ${formatPercent(share(point.point, point.count))}`} onmouseenter={() => (active = { bucket: point.bucket, status: statusSeries.status })} onmouseleave={() => (active = null)} onfocus={() => (active = { bucket: point.bucket, status: statusSeries.status })} onblur={() => (active = null)} />
|
d={linePath(statusSeries.points)}
|
||||||
|
fill="none"
|
||||||
|
stroke={statusColors[statusSeries.status]}
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-dasharray={pattern.dash}
|
||||||
|
stroke-dashoffset={pattern.offset}
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class:status-line-solid={statusSeries.status === 'Paid'}
|
||||||
|
class:status-line-patterned={statusSeries.status !== 'Paid'}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each data as point, bucket}
|
||||||
|
{#each statusOrder as status}
|
||||||
|
{@const plotted = series.find((candidate) => candidate.status === status)?.points[bucket]}
|
||||||
|
{#if plotted}
|
||||||
|
<circle
|
||||||
|
cx={plotted.x}
|
||||||
|
cy={plotted.y}
|
||||||
|
r={collisionRadius(point, status, bucket)}
|
||||||
|
fill={activeBucket === bucket ? statusColors[status] : 'var(--card)'}
|
||||||
|
stroke={statusColors[status]}
|
||||||
|
stroke-width={activeBucket === bucket ? 3 : 2.5}
|
||||||
|
class="status-dot"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if activeX !== null}
|
||||||
|
<line
|
||||||
|
x1={activeX}
|
||||||
|
x2={activeX}
|
||||||
|
y1={pad.top}
|
||||||
|
y2={height - pad.bottom}
|
||||||
|
stroke="var(--foreground)"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-dasharray="3 5"
|
||||||
|
class="bucket-guide"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each hoverZones as zone}
|
||||||
|
<rect
|
||||||
|
x={zone.start}
|
||||||
|
y={pad.top}
|
||||||
|
width={zone.width}
|
||||||
|
height={plotHeight}
|
||||||
|
fill="transparent"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label={bucketAria(zone.point)}
|
||||||
|
onmouseenter={() => (activeBucket = zone.bucket)}
|
||||||
|
onfocus={() => (activeBucket = zone.bucket)}
|
||||||
|
onblur={() => (activeBucket = null)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{#if active !== null && activePoint}
|
{#if activePoint && activeX !== null}
|
||||||
<div class="pointer-events-none absolute z-20 w-48 transition-[left,top,transform] duration-150 motion-reduce:transition-none" style={tooltipStyle(activePoint)} dir="rtl" role="tooltip">
|
<div
|
||||||
<div class="rounded-md bg-zinc-950 p-3 text-right text-xs leading-5 text-white shadow-xl"><strong class="block">{bucketLabel(activePoint.point)}</strong><span class="block">{statusLabels[active.status]}</span><span class="block text-white/75">{formatValue(activePoint.count)} تراکنش · {formatPercent(share(activePoint.point, activePoint.count))}</span></div>
|
class="pointer-events-none absolute z-20 w-56 transition-[left,top,transform] duration-200 ease-out motion-reduce:transition-none"
|
||||||
|
style={tooltipStyle(activeX)}
|
||||||
|
dir="rtl"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
<div class="rounded-md bg-zinc-950 p-3 text-right text-xs leading-5 text-white shadow-xl">
|
||||||
|
<strong class="mb-2 block">{bucketLabel(activePoint)}</strong>
|
||||||
|
<div class="grid gap-1.5">
|
||||||
|
{#each statusOrder as status}
|
||||||
|
{@const count = countFor(activePoint, status)}
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="inline-flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="size-2 shrink-0 rounded-full"
|
||||||
|
style={`background:${statusColors[status]}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
<span class="truncate">{statusLabels[status]}</span>
|
||||||
|
</span>
|
||||||
|
<strong class="shrink-0 tabular-nums">
|
||||||
|
{formatValue(count)} · {formatPercent(share(activePoint, count))}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 border-t border-white/15 pt-2 text-white/75">
|
||||||
|
کل: {formatValue(activePoint.totalSessions)} تراکنش
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -116,14 +304,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="border-border rounded-md border">
|
<details class="border-border rounded-md border">
|
||||||
<summary class="focus-visible:ring-ring cursor-pointer rounded-md px-4 py-3 text-sm font-semibold focus-visible:ring-2">جدول دادههای روند وضعیت</summary>
|
<summary
|
||||||
|
class="focus-visible:ring-ring cursor-pointer rounded-md px-4 py-3 text-sm font-semibold focus-visible:ring-2"
|
||||||
|
>جدول دادههای روند وضعیت</summary
|
||||||
|
>
|
||||||
<div class="overflow-x-auto border-t">
|
<div class="overflow-x-auto border-t">
|
||||||
<table class="w-full min-w-[760px] text-sm">
|
<table class="w-full min-w-[760px] text-sm">
|
||||||
<caption class="sr-only">تعداد تراکنشها به تفکیک زمان و وضعیت</caption>
|
<caption class="sr-only">تعداد تراکنشها به تفکیک زمان و وضعیت</caption>
|
||||||
<thead class="bg-muted/50"><tr><th class="p-3 text-right">زمان</th>{#each statusOrder as status}<th class="p-3 text-right">{statusLabels[status]}</th>{/each}<th class="p-3 text-right">کل</th></tr></thead>
|
<thead class="bg-muted/50"
|
||||||
|
><tr
|
||||||
|
><th class="p-3 text-right">زمان</th>{#each statusOrder as status}<th class="p-3 text-right"
|
||||||
|
>{statusLabels[status]}</th
|
||||||
|
>{/each}<th class="p-3 text-right">کل</th></tr
|
||||||
|
></thead
|
||||||
|
>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data as point}
|
{#each data as point}
|
||||||
<tr class="border-t"><th class="p-3 text-right font-medium">{bucketLabel(point)}</th>{#each statusOrder as status}{@const count = countFor(point, status)}<td class="p-3 tabular-nums">{formatValue(count)} <span class="text-muted-foreground">({formatPercent(share(point, count))})</span></td>{/each}<td class="p-3 font-semibold tabular-nums">{formatValue(point.totalSessions)}</td></tr>
|
<tr class="border-t"
|
||||||
|
><th class="p-3 text-right font-medium">{bucketLabel(point)}</th
|
||||||
|
>{#each statusOrder as status}{@const count = countFor(point, status)}<td
|
||||||
|
class="p-3 tabular-nums"
|
||||||
|
>{formatValue(count)}
|
||||||
|
<span class="text-muted-foreground">({formatPercent(share(point, count))})</span></td
|
||||||
|
>{/each}<td class="p-3 font-semibold tabular-nums">{formatValue(point.totalSessions)}</td></tr
|
||||||
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -132,7 +336,66 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.status-line { stroke-dasharray: 1800; stroke-dashoffset: 1800; animation: draw-status-line 800ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
|
.status-line-solid {
|
||||||
@keyframes draw-status-line { to { stroke-dashoffset: 0; } }
|
stroke-dasharray: 1800;
|
||||||
@media (prefers-reduced-motion: reduce) { .status-line { animation: none; stroke-dasharray: none; stroke-dashoffset: 0; } }
|
stroke-dashoffset: 1800;
|
||||||
|
animation: draw-status-line 800ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
}
|
||||||
|
.status-line-patterned {
|
||||||
|
animation: reveal-status-line 650ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
transition:
|
||||||
|
r 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
fill 180ms ease-out,
|
||||||
|
stroke-width 180ms ease-out;
|
||||||
|
}
|
||||||
|
[role='tooltip'] {
|
||||||
|
animation: tooltip-enter 160ms ease-out both;
|
||||||
|
}
|
||||||
|
.bucket-guide {
|
||||||
|
opacity: 0.09;
|
||||||
|
transition:
|
||||||
|
x 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 160ms ease-out;
|
||||||
|
}
|
||||||
|
@keyframes draw-status-line {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes reveal-status-line {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes tooltip-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.status-line-solid,
|
||||||
|
.status-line-patterned {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.status-line-solid {
|
||||||
|
stroke-dasharray: none;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
.status-dot,
|
||||||
|
.bucket-guide,
|
||||||
|
[role='tooltip'] {
|
||||||
|
transition: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { render, screen } from '@testing-library/svelte';
|
import { fireEvent, render, screen, within } from '@testing-library/svelte';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import TransactionStatusTrendChart from './TransactionStatusTrendChart.svelte';
|
import TransactionStatusTrendChart from './TransactionStatusTrendChart.svelte';
|
||||||
|
|
||||||
describe('TransactionStatusTrendChart', () => {
|
describe('TransactionStatusTrendChart', () => {
|
||||||
it('exposes count and percentage for every status segment and a semantic table', () => {
|
it('shows every status for the hovered time bucket and keeps a semantic table', async () => {
|
||||||
render(TransactionStatusTrendChart, {
|
render(TransactionStatusTrendChart, {
|
||||||
periodType: 'DAY',
|
periodType: 'DAY',
|
||||||
data: [
|
data: [
|
||||||
@@ -20,14 +20,23 @@ describe('TransactionStatusTrendChart', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: /۰۸:۰۰، تأیید نهایی، ۶ تراکنش، ۶۰٪/ })).toBeTruthy();
|
const bucket = screen.getByRole('button', {
|
||||||
expect(screen.getByRole('button', { name: /۰۸:۰۰، ناموفق، ۲ تراکنش، ۲۰٪/ })).toBeTruthy();
|
name: /۰۸:۰۰.*پرداختشده، در انتظار تأیید.*تأیید نهایی.*ناموفق.*برگشتخورده/,
|
||||||
|
});
|
||||||
|
await fireEvent.mouseEnter(bucket);
|
||||||
|
|
||||||
|
const tooltip = screen.getByRole('tooltip');
|
||||||
|
expect(within(tooltip).getByText('پرداختشده، در انتظار تأیید')).toBeTruthy();
|
||||||
|
expect(within(tooltip).getByText('تأیید نهایی')).toBeTruthy();
|
||||||
|
expect(within(tooltip).getByText('ناموفق')).toBeTruthy();
|
||||||
|
expect(within(tooltip).getByText('برگشتخورده')).toBeTruthy();
|
||||||
|
expect(within(tooltip).getByText('۶ · ۶۰٪')).toBeTruthy();
|
||||||
expect(screen.getByText('جدول دادههای روند وضعیت')).toBeTruthy();
|
expect(screen.getByText('جدول دادههای روند وضعیت')).toBeTruthy();
|
||||||
expect(screen.getByRole('table')).toBeTruthy();
|
expect(screen.getByRole('table')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps zero-filled buckets keyboard inspectable', () => {
|
it('keeps coincident zero-filled buckets keyboard inspectable and visually distinct', () => {
|
||||||
render(TransactionStatusTrendChart, {
|
const { container } = render(TransactionStatusTrendChart, {
|
||||||
periodType: 'WEEK',
|
periodType: 'WEEK',
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
@@ -41,6 +50,8 @@ describe('TransactionStatusTrendChart', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getAllByRole('button', { name: /۰ تراکنش، ۰٪/ })).toHaveLength(4);
|
expect(screen.getByRole('button', { name: /پرداختشده، در انتظار تأیید ۰ تراکنش ۰٪/ })).toBeTruthy();
|
||||||
|
const radii = [...container.querySelectorAll('circle')].map((circle) => circle.getAttribute('r'));
|
||||||
|
expect(new Set(radii).size).toBe(4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user