fix(arayüz): erişilebilirlik ve telefon düzeni taraması (kontrast, dokunma alanı, taşma, %80 bütçe uyarısı, boş durumlar)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 22:58:21 +03:00
parent 77d7a081b3
commit 57272a4c7f
18 changed files with 156 additions and 41 deletions
+29 -2
View File
@@ -15,12 +15,39 @@ const components: Components = {
<table {...props} />
</div>
),
// `- [ ]` kutucuğu salt okunur; ekran okuyucu ne olduğunu söylesin
input: ({ node: _node, ...props }) =>
props.type === 'checkbox' ? (
<input {...props} aria-label={props.checked ? 'Tamamlandı' : 'Yapılacak'} />
) : (
<input {...props} />
),
}
export function Markdown({ children, className }: { children: string; className?: string }) {
/**
* Sayfanın kendi `h1`'i varken belge başlıkları bir düzey aşağı (h1 → h2 …); görünüm
* `md-h1`… sınıflarıyla korunur (sayfada tek `h1`, ekran okuyucuda doğru başlık sırası).
*/
const shifted: Components = {
...components,
h1: ({ node: _node, ...p }) => <h2 {...p} className="md-h1" />,
h2: ({ node: _node, ...p }) => <h3 {...p} className="md-h2" />,
h3: ({ node: _node, ...p }) => <h4 {...p} className="md-h3" />,
h4: ({ node: _node, ...p }) => <h5 {...p} className="md-h4" />,
}
export function Markdown({
children,
className,
shiftHeadings = false,
}: {
children: string
className?: string
shiftHeadings?: boolean
}) {
return (
<div className={cn('markdown', className)}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={shiftHeadings ? shifted : components}>
{children}
</ReactMarkdown>
</div>
+4 -3
View File
@@ -20,17 +20,18 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline",
},
size: {
// Kakeibo: mobile-first — dokunma alanı en az 44×44 px (KAKEIBO_PROMPT §9)
// Kakeibo: mobile-first — dokunma alanı en az 44×44 px (KAKEIBO_PROMPT §9);
// sm / icon-sm yalnız fare ile kompakt, dokunmatikte (pointer: coarse) 44 px
default:
"h-11 gap-2 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-9 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
sm: "h-9 pointer-coarse:h-11 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-12 gap-2 px-5 text-base has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4",
icon: "size-11",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-9 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"size-9 pointer-coarse:size-11 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-12",
},
},
+3 -2
View File
@@ -87,7 +87,7 @@ function LedgerView({ period }: { period: Period }) {
)}
<div className="grid items-start gap-4 lg:grid-cols-2">
{!readOnly && (
<div ref={formRef} className="scroll-mt-4 lg:sticky lg:top-4">
<div ref={formRef} className="min-w-0 scroll-mt-4 lg:sticky lg:top-4">
<Section title={editing ? t.ledger.editTitle : t.ledger.formTitle}>
{catalogFailed?.error ? (
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
@@ -110,7 +110,8 @@ function LedgerView({ period }: { period: Period }) {
</Section>
</div>
)}
<div className={readOnly ? 'space-y-4 lg:col-span-2' : 'space-y-4'}>
{/* min-w-0: ızgara öğesi içeriğinin doğal genişliğine uzayıp telefonda taşmasın */}
<div className={readOnly ? 'min-w-0 space-y-4 lg:col-span-2' : 'min-w-0 space-y-4'}>
<Section title={t.ledger.summaryTitle}>
{budget.isError ? (
<PageError message={budget.error.message} onRetry={budget.refetch} />
+14 -2
View File
@@ -1,4 +1,6 @@
import { formatMoney } from '@/lib/format'
import { TriangleAlert } from 'lucide-react'
import { budgetLevel } from '@/lib/budget-level'
import { formatMoney, formatPercent } from '@/lib/format'
import { group } from '@/lib/groups'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
@@ -20,6 +22,7 @@ export function WeekSummary({ budget, index }: { budget: GroupBudget[]; index: n
const planned = index === 'month' ? b.monthly : (w?.amount ?? 0)
const spent = index === 'month' ? b.spent : (w?.spent ?? 0)
const left = planned - spent
const level = budgetLevel(spent, planned)
const over = left < 0
const ratio = planned > 0 ? Math.min(spent / planned, 1) : spent > 0 ? 1 : 0
return (
@@ -41,10 +44,19 @@ export function WeekSummary({ budget, index }: { budget: GroupBudget[]; index: n
aria-valuenow={Math.round(ratio * 100)}
>
<div
className={cn('h-full rounded-full', over ? 'bg-expense' : g.className.fill)}
className={cn(
'h-full rounded-full',
over ? 'bg-expense' : level === 'near' ? 'bg-warning' : g.className.fill,
)}
style={{ width: `${ratio * 100}%` }}
/>
</div>
{level === 'near' && (
<p className="mb-1 flex items-center gap-1 text-xs font-medium text-warning">
<TriangleAlert className="size-3.5 shrink-0" aria-hidden />
{s.near(formatPercent(spent / planned, 0))}
</p>
)}
<p className="text-xs text-muted-foreground">
{s.spent} <span className="money">{formatMoney(spent)}</span> / {s.budget}{' '}
<span className="money">{formatMoney(planned)}</span>
+1 -1
View File
@@ -54,7 +54,7 @@ export function Section({
className?: string
}) {
return (
<section className={cn('rounded-2xl border bg-card p-4 sm:p-5', className)}>
<section className={cn('min-w-0 rounded-2xl border bg-card p-4 sm:p-5', className)}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">{title}</h3>
{action}
+35 -2
View File
@@ -1,5 +1,6 @@
import { Printer } from 'lucide-react'
import { useSearchParams } from 'react-router'
import { ChartLine, Printer } from 'lucide-react'
import { Link, useSearchParams } from 'react-router'
import { paths } from '@/components/layout/nav-items'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Skeleton } from '@/components/skeleton'
import { Button } from '@/components/ui/button'
@@ -66,6 +67,8 @@ export function ReportsPage() {
<PageError message={failed.error.message} onRetry={failed.refetch} />
) : !periods.data || !categories.data || !methods.data ? (
<PageLoading />
) : periods.data.length === 0 ? (
<EmptyReports />
) : (
<div className="space-y-4">
{/* Yalnız yazdırmada: kağıtta hangi kapsamın basıldığı belli olsun */}
@@ -87,6 +90,13 @@ export function ReportsPage() {
<PageError message={breakdown.error.message} onRetry={breakdown.refetch} />
) : !breakdown.data ? (
<Skeleton className="h-64" />
) : breakdown.data.count === 0 ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border bg-card p-4">
<p className="text-muted-foreground">{s.noMatch}</p>
<Button variant="outline" onClick={() => setFilter({})}>
{s.clearFilters}
</Button>
</div>
) : (
<div className="grid items-start gap-4 lg:grid-cols-2 print:block print:space-y-4">
<GroupsReport b={breakdown.data} />
@@ -112,3 +122,26 @@ export function ReportsPage() {
</>
)
}
/** Hiç dönem yokken: rapora veri getirecek iki yol */
function EmptyReports() {
return (
<section className="flex flex-col items-center gap-4 rounded-2xl border bg-card p-8 text-center">
<div className="grid size-14 place-items-center rounded-full bg-primary/10 text-primary">
<ChartLine className="size-7" aria-hidden />
</div>
<div className="max-w-md space-y-2">
<h2 className="text-lg font-semibold">{s.emptyTitle}</h2>
<p className="text-muted-foreground">{s.emptyText}</p>
</div>
<div className="flex flex-wrap justify-center gap-2">
<Button asChild size="lg">
<Link to={paths.periodWizard}>{s.emptyStart}</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link to={paths.statement}>{s.emptyImport}</Link>
</Button>
</div>
</section>
)
}
+1 -1
View File
@@ -37,7 +37,7 @@ export function ReportSection({
ref={ref}
data-report
aria-labelledby={`${id}-title`}
className="rounded-2xl border bg-card p-4 sm:p-5 print:rounded-none print:border-0 print:p-0 print:pb-6"
className="min-w-0 rounded-2xl border bg-card p-4 sm:p-5 print:rounded-none print:border-0 print:p-0 print:pb-6"
>
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
@@ -15,6 +15,6 @@ describe('close schema', () => {
expect(schema.safeParse({ to_savings: '-5' }).success).toBe(false)
const r = schema.safeParse({ to_savings: '10.000,01' })
expect(r.success).toBe(false)
expect(r.error?.issues[0].message).toBe('En fazla 10.000,00 ₺ aktarabilirsin')
expect(r.error?.issues[0].message).toBe('En fazla 10.000,00\u00a0₺ aktarabilirsin')
})
})
+3 -1
View File
@@ -65,7 +65,9 @@ function ReportView({ period }: { period: Period }) {
</div>
<p className="text-xs text-muted-foreground print:hidden">{s.printHint}</p>
<article className="rounded-2xl border bg-card p-4 sm:p-8 print:rounded-none print:border-0 print:p-0">
<Markdown className="report">{report.data}</Markdown>
<Markdown className="report" shiftHeadings>
{report.data}
</Markdown>
</article>
</div>
)
+13 -10
View File
@@ -77,7 +77,8 @@
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
/* 0.52: gri (muted) zeminde de WCAG AA ≥ 4.5:1 (0.556 → 4.35 idi) */
--muted-foreground: oklch(0.52 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
@@ -115,7 +116,8 @@
--unexpected-soft: #fbefe5;
/* Durum: gelir/olumlu · gider/aşım · uyarı (yazı olarak AA uyumlu) */
--income: #15803d;
/* yumuşak yeşil zeminde de AA (#15803d → 4.37 idi) */
--income: #166534;
--expense: #c62828;
--warning: #b45309;
}
@@ -220,7 +222,8 @@
@apply text-base font-semibold;
}
.markdown h3,
.markdown h4 {
.markdown h4,
.markdown h5 {
@apply font-semibold;
}
.markdown ul {
@@ -272,16 +275,16 @@
.markdown.report {
@apply space-y-4 text-[0.95rem];
}
.markdown.report h1 {
.markdown.report .md-h1 {
@apply text-2xl font-bold tracking-tight;
}
.markdown.report h2 {
.markdown.report .md-h2 {
@apply mt-8 border-t pt-6 text-xl font-semibold;
}
.markdown.report h3 {
.markdown.report .md-h3 {
@apply mt-5 text-base;
}
.markdown.report h1 + p {
.markdown.report .md-h1 + p {
@apply text-muted-foreground;
}
.markdown.report blockquote {
@@ -314,9 +317,9 @@
.markdown.report ul:has(> li > input[type='checkbox']) {
break-inside: avoid;
}
.markdown.report h2,
.markdown.report h3,
.markdown.report h4 {
.markdown.report .md-h2,
.markdown.report .md-h3,
.markdown.report .md-h4 {
break-after: avoid;
}
html[data-print-one] main [data-report]:not([data-print-target]) {
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { budgetLevel } from './budget-level'
describe('budget level', () => {
it('warns from 80% and flags overspending', () => {
expect(budgetLevel(0, 0)).toBe('none')
expect(budgetLevel(500, 0)).toBe('over')
expect(budgetLevel(79_99, 100_00)).toBe('ok')
expect(budgetLevel(80_00, 100_00)).toBe('near')
expect(budgetLevel(100_00, 100_00)).toBe('near')
expect(budgetLevel(100_01, 100_00)).toBe('over')
})
})
+11
View File
@@ -0,0 +1,11 @@
/** Bütçe kullanım düzeyi (KAKEIBO_PROMPT §9: %80'e yaklaşınca ve aşınca görsel uyarı) */
export type BudgetLevel = 'none' | 'ok' | 'near' | 'over'
/** Uyarı eşiği: bütçenin %80'i */
export const NEAR_RATIO = 0.8
export function budgetLevel(spent: number, planned: number): BudgetLevel {
if (planned <= 0) return spent > 0 ? 'over' : 'none'
if (spent > planned) return 'over'
return spent / planned >= NEAR_RATIO ? 'near' : 'ok'
}
+12 -11
View File
@@ -14,25 +14,25 @@ import {
describe('formatMoney', () => {
it('kuruşu Türkçe biçimde, para birimi sağda ve boşluklu yazar', () => {
expect(formatMoney(125000)).toBe('1.250,00 ₺')
expect(formatMoney(30000, 'EUR')).toBe('300,00 €')
expect(formatMoney(15000, 'USD')).toBe('150,00 $')
expect(formatMoney(0)).toBe('0,00 ₺')
expect(formatMoney(5)).toBe('0,05 ₺')
expect(formatMoney(123456789)).toBe('1.234.567,89 ₺')
expect(formatMoney(125000)).toBe('1.250,00\u00a0₺')
expect(formatMoney(30000, 'EUR')).toBe('300,00\u00a0€')
expect(formatMoney(15000, 'USD')).toBe('150,00\u00a0$')
expect(formatMoney(0)).toBe('0,00\u00a0₺')
expect(formatMoney(5)).toBe('0,05\u00a0₺')
expect(formatMoney(123456789)).toBe('1.234.567,89\u00a0₺')
})
it('negatif ve işaretli', () => {
expect(formatMoney(-125000)).toBe('-1.250,00 ₺')
expect(formatMoney(125000, 'TRY', { signed: true })).toBe('+1.250,00 ₺')
expect(formatMoney(-500, 'TRY', { signed: true })).toBe('-5,00 ₺')
expect(formatMoney(-125000)).toBe('-1.250,00\u00a0₺')
expect(formatMoney(125000, 'TRY', { signed: true })).toBe('+1.250,00\u00a0₺')
expect(formatMoney(-500, 'TRY', { signed: true })).toBe('-5,00\u00a0₺')
})
})
describe('diğer biçimler', () => {
it('miktar, sayı, yüzde', () => {
expect(formatQuantity(12_500_000, 'gr')).toBe('12,50 gr')
expect(formatQuantity(1_234_567, 'gr', 3)).toBe('1,235 gr')
expect(formatQuantity(12_500_000, 'gr')).toBe('12,50\u00a0gr')
expect(formatQuantity(1_234_567, 'gr', 3)).toBe('1,235\u00a0gr')
expect(formatNumber(1250.5)).toBe('1.250,50')
expect(formatPercent(0.123)).toBe('%12,3')
})
@@ -60,6 +60,7 @@ describe('parseMoney', () => {
['1250.50', 125050], // telefon klavyesindeki nokta
['1250.5', 125050],
[' 1.250,00 ₺ ', 125000],
['1.250,00\u00a0₺', 125000], // ekrandan kopyalanan (bölünmez boşluk)
[',99', 99],
['0,1', 10],
['1.234.567,89', 123456789],
+3 -2
View File
@@ -47,13 +47,14 @@ export function formatMoney(
currency: CurrencyCode = 'TRY',
options: { signed?: boolean } = {},
): string {
const text = `${formatNumber(kurus / 100)} ${CURRENCY_SUFFIX[currency]}`
// Bölünmez boşluk: satır sonunda "₺" tek başına alt satıra düşmesin
const text = `${formatNumber(kurus / 100)}\u00a0${CURRENCY_SUFFIX[currency]}`
return options.signed && kurus > 0 ? `+${text}` : text
}
/** Milyonda bir birim → `12,50 gr` (altın, döviz miktarı) */
export function formatQuantity(e6: number, unit: string, decimals = 2): string {
return `${formatNumber(e6 / 1_000_000, decimals)} ${unit}`
return `${formatNumber(e6 / 1_000_000, decimals)}\u00a0${unit}`
}
/** Yüzde: `0.123` → `%12,3` (Türkçede % işareti başta) */
+8
View File
@@ -296,6 +296,7 @@ export const t = {
spent: 'Harcanan',
left: 'Kalan',
over: 'Aşıldı',
near: (pct: string) => `Bütçe kullanımı ${pct}`,
},
settings: {
@@ -638,6 +639,13 @@ export const t = {
},
reports: {
emptyTitle: 'Henüz rapora girecek veri yok',
emptyText:
'Raporlar harcamalardan oluşur. Bu ayı başlatıp Defter’e harcama girebilir ya da yıl başından beri olan ayları banka / kart ekstresinden aktarabilirsin.',
emptyStart: 'Dönemi başlat',
emptyImport: 'Ekstreden geçmiş ayları aktar',
noMatch: 'Bu filtrelerle harcama yok.',
clearFilters: 'Filtreleri temizle',
printAll: 'Tümünü yazdır',
print: 'Yazdır',
csv: 'CSV indir',