feat: show hourly labels for daily charts

This commit is contained in:
2026-08-21 16:28:13 +08:00
parent 3db3bd18bb
commit 5616a330cd
4 changed files with 67 additions and 7 deletions

View File

@@ -723,6 +723,8 @@ export interface components {
PaymentHistoryPoint: { PaymentHistoryPoint: {
/** Format: date */ /** Format: date */
localDate: string; localDate: string;
/** @description Tehran-local hour bucket, present only for DAY periods. */
localHour?: number;
/** Format: int64 */ /** Format: int64 */
totalSessions: number; totalSessions: number;
/** Format: int64 */ /** Format: int64 */

View File

@@ -177,14 +177,18 @@
><ChartNoAxesCombinedIcon class="text-primary size-5" /> روند پرداخت‌های موفق</CardTitle ><ChartNoAxesCombinedIcon class="text-primary size-5" /> روند پرداخت‌های موفق</CardTitle
> >
<p class="text-muted-foreground mt-1 text-xs"> <p class="text-muted-foreground mt-1 text-xs">
برای دیدن مبلغ و تعداد هر روز، روی نقطه‌های نمودار بروید. برای دیدن مبلغ و تعداد هر {run.period.periodType === 'DAY' ? 'ساعت' : 'روز'}، روی نقطه‌های
نمودار بروید.
</p> </p>
</div> </div>
<Badge variant="secondary" class="rounded-md">{run.period.jalaliLabelFa}</Badge> <Badge variant="secondary" class="rounded-md">{run.period.jalaliLabelFa}</Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent class="px-3 pt-5 sm:px-6" <CardContent class="px-3 pt-5 sm:px-6"
><PaymentHistoryChart data={overview.data.paymentHistory} /></CardContent ><PaymentHistoryChart
data={overview.data.paymentHistory}
periodType={run.period.periodType}
/></CardContent
> >
</Card> </Card>

View File

@@ -5,7 +5,8 @@
import { formatMoney, formatValue } from '$lib/format'; import { formatMoney, formatValue } from '$lib/format';
type Point = components['schemas']['PaymentHistoryPoint']; type Point = components['schemas']['PaymentHistoryPoint'];
let { data }: { data: Point[] } = $props(); type PeriodType = components['schemas']['PeriodType'];
let { data, periodType }: { data: Point[]; periodType: PeriodType } = $props();
let active = $state<number | null>(null); let active = $state<number | null>(null);
const width = 760; const width = 760;
@@ -40,6 +41,27 @@
); );
} }
function persianHour(value: number) {
const hour = new Intl.NumberFormat('fa-IR', {
minimumIntegerDigits: 2,
maximumFractionDigits: 0,
useGrouping: false,
}).format(value);
return `${hour}:۰۰`;
}
function axisLabel(point: Point) {
return periodType === 'DAY' && point.localHour !== undefined
? persianHour(point.localHour)
: persianDate(point.localDate);
}
function fullPointLabel(point: Point) {
return periodType === 'DAY' && point.localHour !== undefined
? `${persianDate(point.localDate)}، ساعت ${persianHour(point.localHour)}`
: persianDate(point.localDate);
}
function tooltipPosition(point: (typeof positions)[number]) { function tooltipPosition(point: (typeof positions)[number]) {
const horizontalPercent = (point.x / width) * 100; const horizontalPercent = (point.x / width) * 100;
const verticalPercent = (point.y / height) * 100; const verticalPercent = (point.y / height) * 100;
@@ -104,7 +126,7 @@
fill="var(--muted-foreground)" fill="var(--muted-foreground)"
font-size="9" font-size="9"
text-anchor="middle" text-anchor="middle"
direction="rtl">{persianDate(tick.localDate)}</text direction="rtl">{axisLabel(tick)}</text
> >
{/each} {/each}
<text <text
@@ -113,7 +135,7 @@
fill="var(--muted-foreground)" fill="var(--muted-foreground)"
font-size="10" font-size="10"
text-anchor="middle" text-anchor="middle"
direction="rtl">تاریخ</text direction="rtl">{periodType === 'DAY' ? 'ساعت' : 'تاریخ'}</text
> >
<text <text
x={-((pad.top + height - pad.bottom) / 2)} x={-((pad.top + height - pad.bottom) / 2)}
@@ -147,7 +169,7 @@
stroke-width="3" stroke-width="3"
tabindex="0" tabindex="0"
role="button" role="button"
aria-label={`${persianDate(point.localDate)}، ${formatValue(point.bankPaidSessions)} پرداخت موفق`} aria-label={`${fullPointLabel(point)}، ${formatValue(point.bankPaidSessions)} پرداخت موفق`}
onmouseenter={() => (active = index)} onmouseenter={() => (active = index)}
onmouseleave={() => (active = null)} onmouseleave={() => (active = null)}
onfocus={() => (active = index)} onfocus={() => (active = index)}
@@ -165,7 +187,7 @@
class="rounded-md bg-zinc-950 p-3 text-right text-xs leading-6 text-white shadow-xl" class="rounded-md bg-zinc-950 p-3 text-right text-xs leading-6 text-white shadow-xl"
transition:scale={{ duration: 150, start: 0.96 }} transition:scale={{ duration: 150, start: 0.96 }}
> >
<strong class="block">{persianDate(positions[active].localDate)}</strong> <strong class="block">{fullPointLabel(positions[active])}</strong>
<span class="block text-white/75" <span class="block text-white/75"
>{formatValue(positions[active].bankPaidSessions)} پرداخت موفق</span >{formatValue(positions[active].bankPaidSessions)} پرداخت موفق</span
> >

View File

@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import PaymentHistoryChart from './PaymentHistoryChart.svelte';
describe('PaymentHistoryChart', () => {
it('uses Persian hour labels for a one-day period', () => {
render(PaymentHistoryChart, {
periodType: 'DAY',
data: [
{
localDate: '2026-07-01',
localHour: 0,
totalSessions: 10,
bankPaidSessions: 8,
bankPaidAmountRial: 800_000,
},
{
localDate: '2026-07-01',
localHour: 12,
totalSessions: 14,
bankPaidSessions: 11,
bankPaidAmountRial: 1_100_000,
},
],
});
expect(screen.getByText('۰۰:۰۰')).not.toBeNull();
expect(screen.getByText('۱۲:۰۰')).not.toBeNull();
expect(screen.getByText('ساعت')).not.toBeNull();
});
});