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
+1
View File
@@ -84,6 +84,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--force]
- JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir). - JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir).
- Aylık rapor: `services/monthly_report.rs` (`gather` → `Facts` → saf `analyze` → `render`), şablon `templates/monthly_report.md` (`include_str!`; Dockerfile `templates/`'u kopyalar). Filtreler `tl`/`pct`/`cell`. Şablonda ham HTML yok (arayüz göstermez). Arayüz `features/review/report-page.tsx` (`/degerlendirme/rapor?donem=`), `lib/monthly-report.ts`; `api.getText`; `Markdown` tabloları `.table-wrap` içinde kayar; yazdırma `@page A4` + `PageHeader printHidden`. Cümlelerde sayıdan sonra ek kullanma ("%429'u" gibi ünlü uyumu gerekir) → "X / Y", "kullanımı %N". - Aylık rapor: `services/monthly_report.rs` (`gather` → `Facts` → saf `analyze` → `render`), şablon `templates/monthly_report.md` (`include_str!`; Dockerfile `templates/`'u kopyalar). Filtreler `tl`/`pct`/`cell`. Şablonda ham HTML yok (arayüz göstermez). Arayüz `features/review/report-page.tsx` (`/degerlendirme/rapor?donem=`), `lib/monthly-report.ts`; `api.getText`; `Markdown` tabloları `.table-wrap` içinde kayar; yazdırma `@page A4` + `PageHeader printHidden`. Cümlelerde sayıdan sonra ek kullanma ("%429'u" gibi ünlü uyumu gerekir) → "X / Y", "kullanımı %N".
- Ayarlar: `/ayarlar` menü (+ tema) → alt sayfalar `SettingsSubpage` ("‹ Ayarlar"): `/ayarlar/kategoriler`, `/odeme-turleri`, `/butce` (varsayılan yüzdeler + öneri yöntemi), `/hane` (ad, üyeler, davet bağlantısı `/kayit?davet=`), `/hesap` (ad, şifre), `/veri` (yedek, CSV, ekstre). Kancalar `lib/household.ts`. Şifre değişince `users.sessions_valid_from` → eski jetonlar 401 (`AuthUser`). Katalog yönetimi `features/settings/catalog-editor.tsx` + `lib/catalog.ts` (`useCreateCatalog`/`useUpdateCatalog`/`useReorderCatalog`, `moveId`); sıralama isteği grubun **tüm** kimlikleri (pasifler dahil); grup sonradan değişmez. - Ayarlar: `/ayarlar` menü (+ tema) → alt sayfalar `SettingsSubpage` ("‹ Ayarlar"): `/ayarlar/kategoriler`, `/odeme-turleri`, `/butce` (varsayılan yüzdeler + öneri yöntemi), `/hane` (ad, üyeler, davet bağlantısı `/kayit?davet=`), `/hesap` (ad, şifre), `/veri` (yedek, CSV, ekstre). Kancalar `lib/household.ts`. Şifre değişince `users.sessions_valid_from` → eski jetonlar 401 (`AuthUser`). Katalog yönetimi `features/settings/catalog-editor.tsx` + `lib/catalog.ts` (`useCreateCatalog`/`useUpdateCatalog`/`useReorderCatalog`, `moveId`); sıralama isteği grubun **tüm** kimlikleri (pasifler dahil); grup sonradan değişmez.
- Erişilebilirlik kuralları: `--muted-foreground` açık temada 0.52 (gri zeminde AA); kart bileşenleri (`Section`, rapor bölümü) `min-w-0` — ızgara öğesi içeriğine uzayıp telefonda taşmasın; `Button` `sm`/`icon-sm` dokunmatikte (`pointer-coarse`) 44 px; tutarlar `formatMoney` ile **bölünmez boşluklu** (`1.250,00\u00a0₺`; testlerde `\u00a0`); bütçe çubuğu `budgetLevel` (≥ %80 turuncu + "Bütçe kullanımı %N", aşım kırmızı); `Markdown shiftHeadings` (sayfada tek h1). Cümlelerde sayıdan sonra ek yok.
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404. - Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
## Ortam ## Ortam
+2 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ Sıradaki Adım
**Faz 10 – Adım 3:** Arayüz cilası ve erişilebilirlik — tüm sayfaları (Panel, Defter, Plan, sihirbaz, Değerlendirme, Rapor, Birikim, Raporlar, Ayarlar, giriş/kayıt, 404) telefon 390 / masaüstü 1280 / koyu temada gez, ekran görüntüsüyle bul-düzelt; boş durumlar + eylem, skeleton, %80 bütçe uyarısı (KAKEIBO_PROMPT §9), odak halkası/klavye, kontrast, `prefers-reduced-motion`, riskli işlemlerde onay. Önce tarama listesini çıkar, kullanıcıya özet sun. **Faz 10 – Adım 4:** Performans — `npm run build` çıktısında paket boyutlarını ölç (ana JS, Recharts/SheetJS zaten lazy), sayfa bazlı kod bölme (`React.lazy` route), Axum statik dosyalarda `/assets/*` (hash'li) için `Cache-Control: public, max-age=31536000, immutable`, `index.html` için `no-cache`; sıkıştırma zaten var. Önce/sonra ölçümü raporla.
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅. **Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
## 📌 Alınan Kararlar ## 📌 Alınan Kararlar
@@ -150,3 +150,4 @@
- **Faz 9 – Adım 6b (2026-11-13) → Faz 9 ✅:** `features/review/report-page.tsx` (`paths.monthlyReport = /degerlendirme/rapor`, dönem seçici, Yazdır/PDF `printSection()`, "Markdown indir" `downloadFile` (csv.ts'e genel yardımcı), `reportFileName` = backend slug). Bağlantılar: Değerlendirme başlığı, kapatma sonucu, dönem geçmişinde ayrı simge bağlantı (iç içe `<a>` olmasın). Bulunan/düzeltilen: (1) şablondaki `<small>` dipnot react-markdown'da hiç görünmüyordu → düz `_…_`; (2) telefonda tablolar sayfayı 541 px'e taşırıyordu (mobil emülasyonda `innerWidth` da büyür → kontrol 390 ile) → `Markdown` tablo sarmalayıcısı `.table-wrap`; (3) PDF ilk sayfada sayfa başlığı/dönem seçici → `PageHeader printHidden`; (4) ara raporda başlamamış haftalar "✅ %0" → "—"; (5) hedef kutuları sayfa sonunda bölünüyordu. PDF: Chrome `printToPDF` → 4 sayfa A4, `pdftoppm` ile gözle kontrol. Frontend 135, backend 196 test; e2e 22 kontrol (DB silindi). - **Faz 9 – Adım 6b (2026-11-13) → Faz 9 ✅:** `features/review/report-page.tsx` (`paths.monthlyReport = /degerlendirme/rapor`, dönem seçici, Yazdır/PDF `printSection()`, "Markdown indir" `downloadFile` (csv.ts'e genel yardımcı), `reportFileName` = backend slug). Bağlantılar: Değerlendirme başlığı, kapatma sonucu, dönem geçmişinde ayrı simge bağlantı (iç içe `<a>` olmasın). Bulunan/düzeltilen: (1) şablondaki `<small>` dipnot react-markdown'da hiç görünmüyordu → düz `_…_`; (2) telefonda tablolar sayfayı 541 px'e taşırıyordu (mobil emülasyonda `innerWidth` da büyür → kontrol 390 ile) → `Markdown` tablo sarmalayıcısı `.table-wrap`; (3) PDF ilk sayfada sayfa başlığı/dönem seçici → `PageHeader printHidden`; (4) ara raporda başlamamış haftalar "✅ %0" → "—"; (5) hedef kutuları sayfa sonunda bölünüyordu. PDF: Chrome `printToPDF` → 4 sayfa A4, `pdftoppm` ile gözle kontrol. Frontend 135, backend 196 test; e2e 22 kontrol (DB silindi).
- **Faz 10 – Adım 1 (2026-11-13):** Ayarlar menüsü (`features/settings/page.tsx`: Kategoriler, Ödeme türleri, Veri ve yedek + tema) ve alt sayfalar (`subpage.tsx`, `catalog-pages.tsx`, `data-page.tsx`); ekstre sayfası "‹ Veri ve yedek"e döner. `catalog-editor.tsx`: satırda ↑ ↓ ✎ (44 px, `aria-label` adlı), düzenleme formu ad + emoji (hızlı seçim paleti, `aria-pressed`), Pasifleştir/Yeniden etkinleştir + toast "Geri al" (sürümsüz PATCH), aynı ad hatası backend'den. `lib/catalog.ts` mutasyonları `['categories'|'payment-methods']` + rapor/dönem önbelleğini yeniler. Telefonda grup başlığı bölünüyordu → açıklama alta. Frontend 136 test; e2e 25 kontrol (DB silindi). - **Faz 10 – Adım 1 (2026-11-13):** Ayarlar menüsü (`features/settings/page.tsx`: Kategoriler, Ödeme türleri, Veri ve yedek + tema) ve alt sayfalar (`subpage.tsx`, `catalog-pages.tsx`, `data-page.tsx`); ekstre sayfası "‹ Veri ve yedek"e döner. `catalog-editor.tsx`: satırda ↑ ↓ ✎ (44 px, `aria-label` adlı), düzenleme formu ad + emoji (hızlı seçim paleti, `aria-pressed`), Pasifleştir/Yeniden etkinleştir + toast "Geri al" (sürümsüz PATCH), aynı ad hatası backend'den. `lib/catalog.ts` mutasyonları `['categories'|'payment-methods']` + rapor/dönem önbelleğini yeniler. Telefonda grup başlığı bölünüyordu → açıklama alta. Frontend 136 test; e2e 25 kontrol (DB silindi).
- **Faz 10 – Adım 2 (2026-11-13):** Backend: `PATCH /auth/me` (ad), `POST /auth/me/password` (mevcut şifre + limiter `password:<uid>`, `sessions_valid_from` → diğer oturumlar 401, bu cihaza yeni çerez, "beni hatırla" eski çerezin `exp-iat`'ından), `PATCH /household` (sahip: ad, `budget_split_pct` toplam 100, `budget_suggestion`), `DELETE /household/members/{id}` (sahip; kendini/sahibi çıkaramaz; e-posta `#removed-<id>` → yeniden davet edilebilir). `GET /household` artık `settings` döner. `services/account.rs`. Testler `tests/account.rs` (4) + 2 bilinçli bozma yakalandı. Frontend: `/ayarlar/hane` (ad, üyeler + çıkar onaylı, davet oluştur → panoya kopyala / paylaş / iptal), `/ayarlar/hesap` (ad, e-posta salt okunur, şifre formu: zod tekrar + farklı, sunucu hatası alana), `/ayarlar/butce` (4 yüzde canlı toplam, 50/30/10/10 düğmesi, yöntem radyo; üye salt okunur). Öneri seçici ve sihirbaz hanenin yöntemiyle açılır (veri yoksa yüzdelere düşer). E2E 28 kontrol: **davet bağlantısıyla gerçek kayıt** dahil (`REGISTRATION=invite_only`). Not: başsız Chrome'da pano için `Emulation.setFocusEmulationEnabled`. Backend 205, frontend 140 test. - **Faz 10 – Adım 2 (2026-11-13):** Backend: `PATCH /auth/me` (ad), `POST /auth/me/password` (mevcut şifre + limiter `password:<uid>`, `sessions_valid_from` → diğer oturumlar 401, bu cihaza yeni çerez, "beni hatırla" eski çerezin `exp-iat`'ından), `PATCH /household` (sahip: ad, `budget_split_pct` toplam 100, `budget_suggestion`), `DELETE /household/members/{id}` (sahip; kendini/sahibi çıkaramaz; e-posta `#removed-<id>` → yeniden davet edilebilir). `GET /household` artık `settings` döner. `services/account.rs`. Testler `tests/account.rs` (4) + 2 bilinçli bozma yakalandı. Frontend: `/ayarlar/hane` (ad, üyeler + çıkar onaylı, davet oluştur → panoya kopyala / paylaş / iptal), `/ayarlar/hesap` (ad, e-posta salt okunur, şifre formu: zod tekrar + farklı, sunucu hatası alana), `/ayarlar/butce` (4 yüzde canlı toplam, 50/30/10/10 düğmesi, yöntem radyo; üye salt okunur). Öneri seçici ve sihirbaz hanenin yöntemiyle açılır (veri yoksa yüzdelere düşer). E2E 28 kontrol: **davet bağlantısıyla gerçek kayıt** dahil (`REGISTRATION=invite_only`). Not: başsız Chrome'da pano için `Emulation.setFocusEmulationEnabled`. Backend 205, frontend 140 test.
- **Faz 10 – Adım 3 (2026-11-13):** Tarama betiği (scratchpad `e2e/scan.mjs`): 21 sayfa × telefon açık/koyu + masaüstü (+ boş hane, giriş/kayıt); sayfa içi denetim (adsız denetim, <44 px hedef, canvas ile oklch→sRGB kontrast, tek h1, alt, lang, taşma) + Tab ile odak halkası. İlk sonuç 89 kontrast / 75 küçük hedef / 27 adsız / 3 h1 → benzersiz: muted yazı 4,35 (→ token 0.52), yeşil 4,37 (→ #166534), `sm` düğmeler 36 px (→ pointer-coarse 44), GFM görev kutusu adsız (→ "Tamamlandı/Yapılacak"), rapor 2 h1 (→ `shiftHeadings`). **Ekran görüntüsünden:** telefonda Defter formu taşıyordu (ızgara öğesi `min-width:auto` 425 px) — otomatik kontrol kaçırdı çünkü mobil öykünmede `innerWidth` da büyür → kontrol `screen.width`/390 ile; Raporlar grafik kartları da taşıyordu → `Section` + rapor bölümüne `min-w-0`. Eklenen: %80 bütçe uyarısı (`lib/budget-level.ts`), Raporlar boş durumu (dönem yok → Dönemi başlat / Ekstreden aktar; filtre boş → Filtreleri temizle), tutarlarda bölünmez boşluk. Sonra: kontrast/adsız/h1/telefon hedef 0. Frontend 142 test.
+2 -2
View File
@@ -89,6 +89,6 @@
## Faz 10 Adımları (web uygulamasının eksikleri + cila) ## Faz 10 Adımları (web uygulamasının eksikleri + cila)
- ✅ 1. Ayarlar yeniden düzeni (`/ayarlar` menü + tema) · **Kategori yönetimi** (grup grup ekle, ad/emoji düzenle, sırala, pasifleştir) · **Ödeme türü yönetimi** · Veri sayfası `/ayarlar/veri` - ✅ 1. Ayarlar yeniden düzeni (`/ayarlar` menü + tema) · **Kategori yönetimi** (grup grup ekle, ad/emoji düzenle, sırala, pasifleştir) · **Ödeme türü yönetimi** · Veri sayfası `/ayarlar/veri`
- ✅ 2. Hane ve hesap: hane adı, üyeler, **davet bağlantısı** (oluştur/kopyala/iptal), üye çıkarma; profil adı, **şifre değiştirme**; varsayılan bütçe yüzdeleri + öneri yöntemi - ✅ 2. Hane ve hesap: hane adı, üyeler, **davet bağlantısı** (oluştur/kopyala/iptal), üye çıkarma; profil adı, **şifre değiştirme**; varsayılan bütçe yüzdeleri + öneri yöntemi
- 🔄 3. Arayüz cilası ve erişilebilirlik: tüm sayfaların telefon/masaüstü/koyu tema taraması, boş durumlar, skeleton, %80 bütçe uyarısı, odak/klavye, kontrast, onay diyalogları - ✅ 3. Arayüz cilası ve erişilebilirlik: tüm sayfaların telefon/masaüstü/koyu tema taraması, boş durumlar, skeleton, %80 bütçe uyarısı, odak/klavye, kontrast, onay diyalogları
- ⏳ 4. Performans: sayfa bazlı kod bölme (lazy route), statik dosyalarda uzun önbellek, paket boyutu - 🔄 4. Performans: sayfa bazlı kod bölme (lazy route), statik dosyalarda uzun önbellek, paket boyutu
- ⏳ 5. PWA: manifest + service worker (uygulama kabuğu; `/api` asla önbelleğe alınmaz), kurulum, güncelleme bildirimi - ⏳ 5. PWA: manifest + service worker (uygulama kabuğu; `/api` asla önbelleğe alınmaz), kurulum, güncelleme bildirimi
+29 -2
View File
@@ -15,12 +15,39 @@ const components: Components = {
<table {...props} /> <table {...props} />
</div> </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 ( return (
<div className={cn('markdown', className)}> <div className={cn('markdown', className)}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={shiftHeadings ? shifted : components}>
{children} {children}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
+4 -3
View File
@@ -20,17 +20,18 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { 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: default:
"h-11 gap-2 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", "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", 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", 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: "size-11",
"icon-xs": "icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "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", "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"> <div className="grid items-start gap-4 lg:grid-cols-2">
{!readOnly && ( {!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}> <Section title={editing ? t.ledger.editTitle : t.ledger.formTitle}>
{catalogFailed?.error ? ( {catalogFailed?.error ? (
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} /> <PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
@@ -110,7 +110,8 @@ function LedgerView({ period }: { period: Period }) {
</Section> </Section>
</div> </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}> <Section title={t.ledger.summaryTitle}>
{budget.isError ? ( {budget.isError ? (
<PageError message={budget.error.message} onRetry={budget.refetch} /> <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 { group } from '@/lib/groups'
import { t } from '@/lib/strings' import { t } from '@/lib/strings'
import { cn } from '@/lib/utils' 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 planned = index === 'month' ? b.monthly : (w?.amount ?? 0)
const spent = index === 'month' ? b.spent : (w?.spent ?? 0) const spent = index === 'month' ? b.spent : (w?.spent ?? 0)
const left = planned - spent const left = planned - spent
const level = budgetLevel(spent, planned)
const over = left < 0 const over = left < 0
const ratio = planned > 0 ? Math.min(spent / planned, 1) : spent > 0 ? 1 : 0 const ratio = planned > 0 ? Math.min(spent / planned, 1) : spent > 0 ? 1 : 0
return ( return (
@@ -41,10 +44,19 @@ export function WeekSummary({ budget, index }: { budget: GroupBudget[]; index: n
aria-valuenow={Math.round(ratio * 100)} aria-valuenow={Math.round(ratio * 100)}
> >
<div <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}%` }} style={{ width: `${ratio * 100}%` }}
/> />
</div> </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"> <p className="text-xs text-muted-foreground">
{s.spent} <span className="money">{formatMoney(spent)}</span> / {s.budget}{' '} {s.spent} <span className="money">{formatMoney(spent)}</span> / {s.budget}{' '}
<span className="money">{formatMoney(planned)}</span> <span className="money">{formatMoney(planned)}</span>
+1 -1
View File
@@ -54,7 +54,7 @@ export function Section({
className?: string className?: string
}) { }) {
return ( 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"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">{title}</h3> <h3 className="font-semibold">{title}</h3>
{action} {action}
+35 -2
View File
@@ -1,5 +1,6 @@
import { Printer } from 'lucide-react' import { ChartLine, Printer } from 'lucide-react'
import { useSearchParams } from 'react-router' import { Link, useSearchParams } from 'react-router'
import { paths } from '@/components/layout/nav-items'
import { PageError, PageHeader, PageLoading } from '@/components/page' import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Skeleton } from '@/components/skeleton' import { Skeleton } from '@/components/skeleton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -66,6 +67,8 @@ export function ReportsPage() {
<PageError message={failed.error.message} onRetry={failed.refetch} /> <PageError message={failed.error.message} onRetry={failed.refetch} />
) : !periods.data || !categories.data || !methods.data ? ( ) : !periods.data || !categories.data || !methods.data ? (
<PageLoading /> <PageLoading />
) : periods.data.length === 0 ? (
<EmptyReports />
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{/* Yalnız yazdırmada: kağıtta hangi kapsamın basıldığı belli olsun */} {/* 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} /> <PageError message={breakdown.error.message} onRetry={breakdown.refetch} />
) : !breakdown.data ? ( ) : !breakdown.data ? (
<Skeleton className="h-64" /> <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"> <div className="grid items-start gap-4 lg:grid-cols-2 print:block print:space-y-4">
<GroupsReport b={breakdown.data} /> <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} ref={ref}
data-report data-report
aria-labelledby={`${id}-title`} 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="mb-3 flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
@@ -15,6 +15,6 @@ describe('close schema', () => {
expect(schema.safeParse({ to_savings: '-5' }).success).toBe(false) expect(schema.safeParse({ to_savings: '-5' }).success).toBe(false)
const r = schema.safeParse({ to_savings: '10.000,01' }) const r = schema.safeParse({ to_savings: '10.000,01' })
expect(r.success).toBe(false) 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> </div>
<p className="text-xs text-muted-foreground print:hidden">{s.printHint}</p> <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"> <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> </article>
</div> </div>
) )
+13 -10
View File
@@ -77,7 +77,8 @@
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 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: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
@@ -115,7 +116,8 @@
--unexpected-soft: #fbefe5; --unexpected-soft: #fbefe5;
/* Durum: gelir/olumlu · gider/aşım · uyarı (yazı olarak AA uyumlu) */ /* 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; --expense: #c62828;
--warning: #b45309; --warning: #b45309;
} }
@@ -220,7 +222,8 @@
@apply text-base font-semibold; @apply text-base font-semibold;
} }
.markdown h3, .markdown h3,
.markdown h4 { .markdown h4,
.markdown h5 {
@apply font-semibold; @apply font-semibold;
} }
.markdown ul { .markdown ul {
@@ -272,16 +275,16 @@
.markdown.report { .markdown.report {
@apply space-y-4 text-[0.95rem]; @apply space-y-4 text-[0.95rem];
} }
.markdown.report h1 { .markdown.report .md-h1 {
@apply text-2xl font-bold tracking-tight; @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; @apply mt-8 border-t pt-6 text-xl font-semibold;
} }
.markdown.report h3 { .markdown.report .md-h3 {
@apply mt-5 text-base; @apply mt-5 text-base;
} }
.markdown.report h1 + p { .markdown.report .md-h1 + p {
@apply text-muted-foreground; @apply text-muted-foreground;
} }
.markdown.report blockquote { .markdown.report blockquote {
@@ -314,9 +317,9 @@
.markdown.report ul:has(> li > input[type='checkbox']) { .markdown.report ul:has(> li > input[type='checkbox']) {
break-inside: avoid; break-inside: avoid;
} }
.markdown.report h2, .markdown.report .md-h2,
.markdown.report h3, .markdown.report .md-h3,
.markdown.report h4 { .markdown.report .md-h4 {
break-after: avoid; break-after: avoid;
} }
html[data-print-one] main [data-report]:not([data-print-target]) { 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', () => { describe('formatMoney', () => {
it('kuruşu Türkçe biçimde, para birimi sağda ve boşluklu yazar', () => { 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(125000)).toBe('1.250,00\u00a0₺')
expect(formatMoney(30000, 'EUR')).toBe('300,00 €') expect(formatMoney(30000, 'EUR')).toBe('300,00\u00a0€')
expect(formatMoney(15000, 'USD')).toBe('150,00 $') expect(formatMoney(15000, 'USD')).toBe('150,00\u00a0$')
expect(formatMoney(0)).toBe('0,00 ₺') expect(formatMoney(0)).toBe('0,00\u00a0₺')
expect(formatMoney(5)).toBe('0,05 ₺') expect(formatMoney(5)).toBe('0,05\u00a0₺')
expect(formatMoney(123456789)).toBe('1.234.567,89 ₺') expect(formatMoney(123456789)).toBe('1.234.567,89\u00a0₺')
}) })
it('negatif ve işaretli', () => { it('negatif ve işaretli', () => {
expect(formatMoney(-125000)).toBe('-1.250,00 ₺') expect(formatMoney(-125000)).toBe('-1.250,00\u00a0₺')
expect(formatMoney(125000, 'TRY', { signed: true })).toBe('+1.250,00 ₺') expect(formatMoney(125000, 'TRY', { signed: true })).toBe('+1.250,00\u00a0₺')
expect(formatMoney(-500, 'TRY', { signed: true })).toBe('-5,00 ₺') expect(formatMoney(-500, 'TRY', { signed: true })).toBe('-5,00\u00a0₺')
}) })
}) })
describe('diğer biçimler', () => { describe('diğer biçimler', () => {
it('miktar, sayı, yüzde', () => { it('miktar, sayı, yüzde', () => {
expect(formatQuantity(12_500_000, 'gr')).toBe('12,50 gr') expect(formatQuantity(12_500_000, 'gr')).toBe('12,50\u00a0gr')
expect(formatQuantity(1_234_567, 'gr', 3)).toBe('1,235 gr') expect(formatQuantity(1_234_567, 'gr', 3)).toBe('1,235\u00a0gr')
expect(formatNumber(1250.5)).toBe('1.250,50') expect(formatNumber(1250.5)).toBe('1.250,50')
expect(formatPercent(0.123)).toBe('%12,3') expect(formatPercent(0.123)).toBe('%12,3')
}) })
@@ -60,6 +60,7 @@ describe('parseMoney', () => {
['1250.50', 125050], // telefon klavyesindeki nokta ['1250.50', 125050], // telefon klavyesindeki nokta
['1250.5', 125050], ['1250.5', 125050],
[' 1.250,00 ₺ ', 125000], [' 1.250,00 ₺ ', 125000],
['1.250,00\u00a0₺', 125000], // ekrandan kopyalanan (bölünmez boşluk)
[',99', 99], [',99', 99],
['0,1', 10], ['0,1', 10],
['1.234.567,89', 123456789], ['1.234.567,89', 123456789],
+3 -2
View File
@@ -47,13 +47,14 @@ export function formatMoney(
currency: CurrencyCode = 'TRY', currency: CurrencyCode = 'TRY',
options: { signed?: boolean } = {}, options: { signed?: boolean } = {},
): string { ): 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 return options.signed && kurus > 0 ? `+${text}` : text
} }
/** Milyonda bir birim → `12,50 gr` (altın, döviz miktarı) */ /** Milyonda bir birim → `12,50 gr` (altın, döviz miktarı) */
export function formatQuantity(e6: number, unit: string, decimals = 2): string { 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) */ /** 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', spent: 'Harcanan',
left: 'Kalan', left: 'Kalan',
over: 'Aşıldı', over: 'Aşıldı',
near: (pct: string) => `Bütçe kullanımı ${pct}`,
}, },
settings: { settings: {
@@ -638,6 +639,13 @@ export const t = {
}, },
reports: { 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', printAll: 'Tümünü yazdır',
print: 'Yazdır', print: 'Yazdır',
csv: 'CSV indir', csv: 'CSV indir',