diff --git a/CLAUDE.md b/CLAUDE.md index 65b3dcb..f935816 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ npm run db:restore -- [--force] - `npm run dev` öncesinde DB otomatik başlar (`predev`). - Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler). - Frontend lint: **oxlint** (Vite şablonu varsayılanı; ESLint yerine). `components/ui/**` shadcn dosyaları. -- Frontend: Tailwind v4 (`src/index.css` token'ları), shadcn/ui (style `radix-nova`, `cn` paketi), `@/` → `src/`. Renkler: `bg-needs`/`text-needs-fg`/`bg-needs-soft` (wants, culture, unexpected aynı), `text-income`/`text-expense`/`text-warning`. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü). +- Frontend: Tailwind v4 (`src/index.css` token'ları), shadcn/ui (style `radix-nova`, `cn` paketi), `@/` → `src/`. Renkler: `bg-needs`/`text-needs-fg`/`bg-needs-soft` (wants, culture, unexpected aynı), `text-income`/`text-expense`/`text-warning`. SVG/JS'te (Recharts vb.) ham değişken kullan: `var(--needs)`, `var(--primary)` — `@theme inline` yüzünden `--color-*` derlenmiş CSS'te olmayabilir. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü). - API: `lib/api.ts` (`api.get/post/...`, hata → `ApiError` Türkçe `message`), `lib/query.ts` (QueryClient + `queryKeys`), `lib/auth.ts` (`useMe` → undefined/null/MeResponse, `useLogin`/`useRegister`/`useLogout`). - Tipler: `frontend/src/types/` **elle düzenlenmez**; backend'de DTO'ya `#[derive(TS)] #[ts(export)]` ekle → `npm run types`. İstek yapılarında `optional_fields`. - Sayfalar: `src/router.tsx`; adresler `components/layout/nav-items.ts` (`paths`); sayfa bileşenleri `features//page.tsx`; iskelet `components/layout/app-layout.tsx`. Sayfa başlığı `PageHeader` (sekme başlığını da ayarlar). Metinler `lib/strings.ts` (`t`). diff --git a/backend/src/routes/stats.rs b/backend/src/routes/stats.rs index a7523c4..eca6a93 100644 --- a/backend/src/routes/stats.rs +++ b/backend/src/routes/stats.rs @@ -79,6 +79,8 @@ struct PeriodStatDto { spendable: i64, #[ts(type = "number")] spent: i64, + /// Harcanan, gruplara göre + spent_by_group: GroupAmounts, /// Kapanmamışsa `null` #[ts(type = "number | null")] to_savings: Option, @@ -105,6 +107,7 @@ pub async fn periods( status: x.period.status, spendable: x.spendable, spent: x.spent, + spent_by_group: x.spent_by_group.into(), to_savings: x.period.to_savings, } }) diff --git a/backend/src/services/budget.rs b/backend/src/services/budget.rs index 21e1a7e..c6cf8be 100644 --- a/backend/src/services/budget.rs +++ b/backend/src/services/budget.rs @@ -44,6 +44,8 @@ pub struct Summary { pub budget_total: i64, /// Dönemde gerçekleşen harcama (silinenler hariç) pub spent_total: i64, + /// Aynısı gruplara göre (`Group::ALL` sırasıyla) + pub spent_by_group: GroupAmounts, } pub struct WeekBudget { @@ -139,16 +141,16 @@ pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppR .sum(); let spendable = income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus; - let spent_total = sum_by( + let by_group = sum_by( db, col::ENTRIES, household_id, doc! { "period_id": period_id }, "$group", ) - .await? - .values() - .sum(); + .await?; + let spent_by_group = Group::ALL.map(|g| by_group.get(g.as_str()).copied().unwrap_or(0)); + let spent_total = spent_by_group.iter().sum(); Ok(Summary { income_total, @@ -159,6 +161,7 @@ pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppR spendable, budget_total, spent_total, + spent_by_group, }) } diff --git a/backend/src/services/stats.rs b/backend/src/services/stats.rs index 6d216c0..0ac9e58 100644 --- a/backend/src/services/stats.rs +++ b/backend/src/services/stats.rs @@ -2,7 +2,7 @@ //! //! - `daily`: dönemin her günü için 4 grubun harcaması (harcama olmayan gün 0). //! Günlük/haftalık ve kümülatif seriler istemcide bundan türetilir. -//! - `periods`: son N dönemin harcanabilir/harcanan tutarı (aylık grafik) ve +//! - `periods`: son N dönemin harcanabilir/harcanan tutarı, grup kırılımıyla (aylık grafik) ve //! **toplam birikim** = kapanışlarda birikime aktarılanların toplamı //! (varlık değerleri Faz 8'de bunun yerini alacak). Yeniden açılan dönemin son //! kapanıştaki tutarı, yeniden kapatılana kadar sayılır. @@ -59,6 +59,7 @@ pub struct PeriodStat { pub period: Period, pub spendable: i64, pub spent: i64, + pub spent_by_group: GroupAmounts, } pub struct Stats { @@ -78,6 +79,7 @@ pub async fn periods(db: &Database, household_id: Uuid, limit: usize) -> AppResu period: p, spendable: s.spendable, spent: s.spent_total, + spent_by_group: s.spent_by_group, }); } out.reverse(); diff --git a/backend/tests/stats.rs b/backend/tests/stats.rs index 82a1287..1b1f130 100644 --- a/backend/tests/stats.rs +++ b/backend/tests/stats.rs @@ -123,7 +123,9 @@ async fn period_stats_and_total_savings() { let s = app.register("Test", None).await; let (nov, dec) = two_periods(&app, &s).await; let needs = category(&app, &s, "needs").await; + let culture = category(&app, &s, "culture").await; spend(&app, &s, &dec, "2026-12-05", 40_000, &needs).await; + spend(&app, &s, &dec, "2026-12-06", 2_500, &culture).await; let (st, b) = app.get("/stats/periods", &s).await; assert_eq!(st, StatusCode::OK, "{b}"); @@ -135,6 +137,10 @@ async fn period_stats_and_total_savings() { assert_eq!(list[0]["status"], "closed"); assert_eq!(list[0]["spendable"], 1_000_000); assert_eq!(list[0]["spent"], 300_000); + assert_eq!( + list[0]["spent_by_group"], + json!({ "needs": 300_000, "wants": 0, "culture": 0, "unexpected": 0 }) + ); assert_eq!(list[0]["to_savings"], 500_000); assert_eq!(list[0]["last_date"], "2026-11-30"); assert_eq!(list[1]["id"], dec.as_str()); @@ -142,7 +148,9 @@ async fn period_stats_and_total_savings() { list[1]["spendable"], 2_100_000, "20.000 − 1.000 hedef + 2.000 artı" ); - assert_eq!(list[1]["spent"], 40_000); + assert_eq!(list[1]["spent"], 42_500); + assert_eq!(list[1]["spent_by_group"]["needs"], 40_000); + assert_eq!(list[1]["spent_by_group"]["culture"], 2_500); assert_eq!(list[1]["to_savings"], Value::Null); // limit: en yeni N dönem diff --git a/docs/MEMORY.md b/docs/MEMORY.md index c9ad0f9..5db8aa8 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 7 – Adım 3:** Grafikler (Recharts — yeni bağımlılık, gerekçe ver): 1) harcanabilir (günlere eşit dağıtılmış, kümülatif) vs gerçekleşen (kümülatif), 2) 4 grup kümülatif + harcanabilir çizgisi; sekmeler Günlük (`/periods/{id}/daily`) / Haftalık (aynı veriden `period.weeks` ile) / Aylık (`useStats` → son 12 dönem, harcanabilir vs harcanan). Saf seri hesapları `lib/` + test; koyu tema renkleri CSS token'larından; grafik altında kısa metin özeti (erişilebilirlik). **Bekleyen soru:** kategori yönetimi ekranı Faz 7'den hemen sonra mı (önerilen), Faz 10'da mı? +**Kullanıcıya sorulacak (2 kez yanıtsız kaldı):** kategori/ödeme türü yönetimi ekranı Faz 8'den önce mi (önerilen: gerçek kullanımdan önce kendi kategorilerini kursun), Faz 10'da mı? Yanıta göre PHASES'e adım ekle. Sonra **Faz 8** (birikim/varlık, kur) planı: önce `KAKEIBO_PROMPT.md` §8.7 oku, adımları PHASES'e yaz, onaylat. **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 @@ -119,3 +119,4 @@ - Kararlar (kullanıcı onayı): toplam birikim Faz 8'e kadar = kapanışlarda birikime aktarılanların toplamı; sekmeler Günlük/Haftalık = bu dönem (kümülatif, harcanabilir günlere eşit dağıtılır), Aylık = son 12 dönem; açık dönem yoksa son dönem salt okunur. - **Adım 1:** `services/stats.rs` + `routes/stats.rs`: `GET /periods/{id}/daily` (`DaySpent[]`: dönemin her günü, `spent` = `GroupAmounts`, harcama yoksa 0; `budget::spent_by_group_and_date` artık `pub(crate)`), `GET /stats/periods?limit=12` (`PeriodStats`: `total_saved` tüm dönemlerin `to_savings` toplamı — limitten bağımsız, yeniden açılan dönemin eski tutarı sayılır; `periods` eskiden yeniye, her biri `budget::summary` ile → dönem başına ~5 sorgu, en fazla 24). `tests/stats.rs` (2). Mutasyon 2/2 yakalandı. Backend 118 test. - **Adım 2:** Panel (`features/dashboard/`): `page.tsx` (açık dönem; yoksa `NoPeriodCard` + son dönem salt okunur, `lib/dashboard.ts` → `dashboardPeriod`), `overview.tsx` (kartlar: gelir, sabit, toplam birikim `useStats().total_saved`, borç yalnızca varsa — 3 kartta ızgara 3 sütun; **Anlık durum** DOM'da önce/mobilde üstte, masaüstünde sağda: kalan harcanabilir + `dailyAllowance` = kalan ÷ kalan gün (bugün dahil, kuruş aşağı); gruplar `WeekSummary` artık `index: number | 'month'`, Haftalık/Aylık geçişi; boş durumlar: bütçe dağıtılmamış → Plan, harcama yok → Defter). "Harcama Ekle": masaüstünde başlıkta, mobilde yüzen (alt menünün üstü) → `/defter` `state.focus` → `EntryForm autoFocus`. `components/skeleton.tsx`. `queryKeys.stats = ['periods','stats']` (kapatma/plan ile yenilenir), kayıt mutasyonları da stats'ı yeniler. Frontend 95 test; e2e 22 kontrol (DB silindi). Canlı yayın 2026-11-12 güncellendi (kullanıcı Deploy yaptı; `/api/stats/periods` 401). +- **Adım 3:** Grafikler (`features/dashboard/charts.tsx`, Recharts 3, **lazy** ayrı parça ~110 KB gz): Günlük/Haftalık/Aylık (`Segmented` — `components/segmented.tsx`, Panel grup kartı da kullanıyor). Seriler `lib/charts.ts` (`dailySeries`: harcanabilir günlere eşit + kümülatif harcanan, gelecek gün `null`; `weeklySeries`: hafta sonu planı, içinde bulunulan hafta bugüne kadar; `monthlySeries`: `useStats` dönemleri; `lastPoint`) + test. Backend: `budget::Summary.spent_by_group` → `PeriodStat.spent_by_group` (aylık grup grafiği). Grup grafiğinde harcanabilir çizgisi varsayılan kapalı (ölçeği ezip grup çizgilerini sıfıra yapıştırıyordu), `aria-pressed` düğmeyle açılır. Lejant/ipucu sırası `itemSorter` ile sabit (Recharts 3 varsayılanı alfabetik). Grafik `aria-hidden`, altında metin özeti (figcaption). `formatCompact` (eksen, bölünmez boşluk). **Ders:** `--color-*` değişkenleri `@theme inline` yüzünden derlenmiş CSS'te yok → SVG'de ham `var(--needs)` (CLAUDE.md'ye eklendi); e2e'de çizgi rengi `getComputedStyle` ile denetlenir. Tam sayfa ekran görüntüsü (`captureBeyondViewport`) ResponsiveContainer'ı küçültebilir → grafik için görünen alan görüntüsü al. Frontend 101, backend 118 test; e2e 18 kontrol. `npm audit` uyarıları shadcn CLI'dan (geliştirme aracı; `--force` shadcn'i 1.0'a düşürür → uygulanmadı). **Faz 7 ✅.** diff --git a/docs/PHASES.md b/docs/PHASES.md index 9a98066..35d7afb 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -11,7 +11,7 @@ | **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ✅ | | **Faz 5** | Günlük defter | Harcama girişi | 🔄 | | **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ | -| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | 🔄 | +| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ | | **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ⏳ | | **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ⏳ | | **Faz 10** | PWA, performans, erişilebilirlik, UI cilası | Telefona kurulabilir web uygulaması | ⏳ | @@ -70,4 +70,4 @@ ## Faz 7 Adımları - ✅ 1. Backend: `GET /periods/{id}/daily` (gün × grup harcama; günlük/haftalık grafik verisi) + `GET /stats/periods` (son 12 dönem: harcanabilir, harcanan, birikime aktarılan; aylık grafik ve toplam birikim) - ✅ 2. Frontend: özet kartları (gelir, sabit gider, toplam birikim, devreden borç, grup planlanan/gerçekleşen haftalık–aylık, **anlık durum** en belirgin) + "➕ Harcama Ekle" (mobilde yüzen düğme) + skeleton ve boş durumlar -- ⏳ 3. Frontend: grafikler (Recharts) — kümülatif harcanabilir vs. gerçekleşen; 4 grup + harcanabilir; Günlük / Haftalık / Aylık sekmeleri, koyu tema, erişilebilir özet +- ✅ 3. Frontend: grafikler (Recharts) — kümülatif harcanabilir vs. gerçekleşen; 4 grup + harcanabilir; Günlük / Haftalık / Aylık sekmeleri, koyu tema, erişilebilir özet diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 766fc2b..205260e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "react-hook-form": "^7.92.0", "react-markdown": "^10.1.0", "react-router": "^8.5.1", + "recharts": "^3.10.1", "remark-gfm": "^4.0.1", "shadcn": "^4.23.0", "sonner": "^2.0.8", @@ -2796,6 +2797,36 @@ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.14.0.tgz", + "integrity": "sha512-m/PxMwRe0xLL6XUsAgy0YjInklHcfi9uMwFhJk+QINqjxqPvyc89T5cMjszA5bDmTE0gwGc7zr9LRO4gL+mhaw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.2.9", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/route-pattern": { "version": "0.22.1", "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", @@ -3148,6 +3179,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -3547,6 +3584,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3637,6 +3737,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -4520,6 +4626,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.5.tgz", + "integrity": "sha512-Th8qGXoBalmWsstpkuhmBuTsBQIFqYOJtjlBvNVvBfJ8E3uYusBUwUR8HU20U0qKKQkgCvKyR1YjiozWadk5QA==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.1.tgz", + "integrity": "sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.3.tgz", + "integrity": "sha512-+F+/49swqmGuQJmSKnVMIDH/QkEFV5li9QRj0xm/KhyAUBv099O5PrvpQFxBp24PYph17Mnvt9mjKQV0ii0nMA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.2.tgz", + "integrity": "sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-jsbgV8zIT38ReB9O7X7gcLndTQRONSqNNd+oGIaTBdUug4r5jFmp0N6RY9R5GWbdUDucn0qoTGyfjapT4tDhcg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.3.tgz", + "integrity": "sha512-66cWobCNzcMvlfoaDHCxuIv9TBUWG1BLOWdv9kHHFuCr1JdqAWPvTmsaEYDD5NRMyWZ5+7u0GqiCDfMXMqh3hg==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.1.tgz", + "integrity": "sha512-dm2o+lbKdzg9tY7GEag/dB9hcbodLfwkE/DkvqbELJXaMz0yKzWWhZqhbI7CKubukA7HnRyz0avdtvi7EPxPvw==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.1.tgz", + "integrity": "sha512-Ze7rjrhMzdeulh0/hjcfFj7XLAHOfowtEMfsoG6DU866HPynQNW7Wfen6hzXcqKTv8XzxvF6Hzr5XD6bMHr2ug==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.1.tgz", + "integrity": "sha512-22XaqjyCjpqgda20erhsOjGmC3vvYwTNYniKAd45W5UWLDF43moeOuV9qYpRqobIlsnK0UhnKZjUUJm3VgORUA==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debounce-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", @@ -4552,6 +4779,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -4755,6 +4988,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.53.2", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.2.tgz", + "integrity": "sha512-iUqZ+NlwH4jUgRmXZUxFBsV/9762TQEa1O4IXLFBmDEjVHScBytKuseX23I0+v9ShZ5MYxEx6WKNzm6IPnex8g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4809,6 +5054,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/execa": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", @@ -5132,12 +5383,31 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.2.10", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.2.10.tgz", + "integrity": "sha512-pzANBj+0A9u+prdhBJ14DVi44vCvVUQzBa8DKt6I0AMCokPHtt722BK9IX5bFC70xUSU9TnzLRIJLTGqIzjEFw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inline-style-parser": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.9.tgz", "integrity": "sha512-IttN8BqKLxzUrnTqHI+/hwAJQItBAUjdoHERCDv+oOuX48VP6bzW8+QjOXSfoevzq3YrYAwX1uLHp8TqO9yIeg==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.7.6", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz", @@ -7338,6 +7608,13 @@ } } }, + "node_modules/react-is": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz", + "integrity": "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==", + "license": "MIT", + "peer": true + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -7365,6 +7642,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.1.tgz", + "integrity": "sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7480,6 +7780,51 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -7555,6 +7900,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -8395,6 +8746,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8438,6 +8798,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.4.1", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.4.1.tgz", + "integrity": "sha512-geV4p2GVfVM9lgm9ZRS3pvsbAPS+BUGhM9W9nPzPHTKi+/+KjNrFXdgP6KkG+XspNZuvcXmchcrtklfuQ4gniw==", + "license": "MIT AND ISC AND BSD-3-Clause", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.3.8", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index fc3fb5e..d66cee4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "react-hook-form": "^7.92.0", "react-markdown": "^10.1.0", "react-router": "^8.5.1", + "recharts": "^3.10.1", "remark-gfm": "^4.0.1", "shadcn": "^4.23.0", "sonner": "^2.0.8", diff --git a/frontend/src/components/segmented.tsx b/frontend/src/components/segmented.tsx new file mode 100644 index 0000000..e44dfa0 --- /dev/null +++ b/frontend/src/components/segmented.tsx @@ -0,0 +1,34 @@ +import { cn } from '@/lib/utils' + +/** Tek seçimlik düğme grubu (ör. Haftalık / Aylık). Her düğme ≥ 44 px yüksek. */ +export function Segmented({ + label, + value, + options, + onChange, +}: { + /** Ekran okuyucu için grup adı */ + label: string + value: T + options: readonly { value: T; label: string }[] + onChange: (value: T) => void +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ) +} diff --git a/frontend/src/features/dashboard/charts.tsx b/frontend/src/features/dashboard/charts.tsx new file mode 100644 index 0000000..bf29cb3 --- /dev/null +++ b/frontend/src/features/dashboard/charts.tsx @@ -0,0 +1,264 @@ +import { useState, type ReactNode } from 'react' +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { PageError } from '@/components/page' +import { Segmented } from '@/components/segmented' +import { Skeleton } from '@/components/skeleton' +import { + dailySeries, + lastPoint, + monthlySeries, + weeklySeries, + type ChartPoint, + type ChartRange, +} from '@/lib/charts' +import { formatCompact, formatMoney, toIsoDate } from '@/lib/format' +import { GROUPS } from '@/lib/groups' +import { useDaily, useStats } from '@/lib/periods' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' +import type { Period } from '@/types/Period' + +const s = t.charts + +/** + * Renkler CSS değişkenlerinden → açık/koyu tema kendiliğinden. Ham `:root` değişkenleri + * (`--needs`, `--primary`…) kullanılır: `@theme inline` olduğundan `--color-*` derlenmiş + * CSS'te ancak bir yerde kullanılmışsa bulunur. + */ +const color = { + plan: 'var(--muted-foreground)', + spent: 'var(--primary)', + grid: 'var(--border)', + axis: 'var(--muted-foreground)', +} + +/** Lejant ve ipucunda sabit sıra (Recharts varsayılanı alfabetik): harcanabilir, harcanan, 4 grup */ +const ORDER = ['plan', 'spent', ...GROUPS.map((g) => g.code)] +/** Nokta: içi kart renginde (koyu temada beyaz nokta olmasın), kenarı çizgi renginde */ +const dot = { r: 3, strokeWidth: 2, fill: 'var(--card)' } +const byOrder = (item: { dataKey?: unknown }) => ORDER.indexOf(String(item.dataKey)) + +/** + * Panel grafikleri (Recharts, ayrı parça olarak yüklenir): + * 1) harcanabilir vs gerçekleşen, 2) 4 grup + harcanabilir. Günlük / Haftalık / Aylık. + */ +export default function DashboardCharts({ + period, + spendable, +}: { + period: Period + spendable: number +}) { + const [range, setRange] = useState('daily') + const [today] = useState(() => toIsoDate(new Date())) + // Harcanabilir çizgisi grup çizgilerini sıfıra ezer (ölçek) → isteğe bağlı + const [showPlan, setShowPlan] = useState(false) + const daily = useDaily(period.id) + const stats = useStats() + + const q = range === 'monthly' ? stats : daily + let points: ChartPoint[] | undefined + if (range === 'monthly') points = stats.data && monthlySeries(stats.data.periods) + else if (daily.data) + points = + range === 'daily' + ? dailySeries(daily.data, spendable, today) + : weeklySeries(daily.data, period.weeks, spendable, today) + + return ( +
+
+

+ 📈 {s.title} +

+ +
+ {q.isError ? ( + + ) : !points ? ( +
+ + +
+ ) : ( +
+ + + + + + + setShowPlan((v) => !v)} + className={cn( + 'min-h-11 rounded-lg border px-3 text-xs font-medium', + showPlan ? 'border-primary bg-primary/10' : 'hover:bg-muted', + )} + > + {s.togglePlan} + + } + > + + {showPlan && ( + + )} + {GROUPS.map((g) => ( + + ))} + + +
+ )} +
+ ) +} + +function ChartCard({ + title, + summary, + action, + children, +}: { + title: string + summary: string + action?: ReactNode + children: ReactNode +}) { + return ( +
+
+

{title}

+ {action} +
+ {/* Grafik görsel; içeriği alttaki özet anlatır (ekran okuyucu özeti okur) */} +
+ {children} +
+
{summary}
+
+ ) +} + +function Chart({ + points, + dots, + children, +}: { + points: ChartPoint[] + dots: boolean + children: ReactNode +}) { + return ( + + + + + formatCompact(v)} + /> + (typeof v === 'number' ? formatMoney(v) : '—')} + contentStyle={{ + background: 'var(--popover)', + color: 'var(--popover-foreground)', + border: '1px solid var(--border)', + borderRadius: 12, + fontSize: 13, + }} + /> + + {children} + + + ) +} + +function planSummary(points: ChartPoint[], range: ChartRange): string { + const p = lastPoint(points) + if (!p || p.spent === null) return s.noData + return range === 'monthly' + ? s.monthlySummary(p.label, formatMoney(p.plan), formatMoney(p.spent)) + : s.cumulativeSummary(formatMoney(p.spent), formatMoney(p.plan), range === 'weekly') +} + +function groupSummary(points: ChartPoint[], range: ChartRange): string { + const p = lastPoint(points) + if (!p || !p.spent) return s.noData + const top = GROUPS.map((g) => ({ g, v: p[g.code] ?? 0 })).sort((a, b) => b.v - a.v)[0] + return s.groupSummary( + range === 'monthly' ? p.label : s.untilNow, + `${top.g.emoji} ${top.g.label}`, + formatMoney(top.v), + ) +} diff --git a/frontend/src/features/dashboard/overview.tsx b/frontend/src/features/dashboard/overview.tsx index 5321383..9dff6bd 100644 --- a/frontend/src/features/dashboard/overview.tsx +++ b/frontend/src/features/dashboard/overview.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react' +import { lazy, Suspense, useState } from 'react' import { Link } from 'react-router' import { paths } from '@/components/layout/nav-items' import { PageError } from '@/components/page' +import { Segmented } from '@/components/segmented' import { Skeleton } from '@/components/skeleton' import { Button } from '@/components/ui/button' import { WeekSummary } from '@/features/ledger/week-summary' @@ -19,6 +20,9 @@ import type { PeriodSummary } from '@/types/PeriodSummary' const s = t.dashboard +/** Recharts büyük → grafikler ayrı parça, Panel açılınca arkadan yüklenir */ +const DashboardCharts = lazy(() => import('./charts')) + /** Özet kartları: gelir, sabit gider, toplam birikim, devreden borç, anlık durum, gruplar */ export function Overview({ period, writable }: { period: Period; writable: boolean }) { const summary = useSummary(period.id) @@ -73,6 +77,9 @@ export function Overview({ period, writable }: { period: Period; writable: boole writable={writable} /> + }> + + ) } @@ -186,22 +193,15 @@ function GroupsCard({

🎯 {s.groupsTitle}

-
- {(['week', 'month'] as const).map((m) => ( - - ))} -
+

{mode === 'week' && week diff --git a/frontend/src/lib/charts.test.ts b/frontend/src/lib/charts.test.ts new file mode 100644 index 0000000..3fd04bb --- /dev/null +++ b/frontend/src/lib/charts.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import type { DaySpent } from '@/types/DaySpent' +import type { PeriodStat } from '@/types/PeriodStat' +import { dailySeries, lastPoint, monthlySeries, weeklySeries } from './charts' + +const zero = { needs: 0, wants: 0, culture: 0, unexpected: 0 } +/** 01–10 Aralık; 1. gün Zorunlu 100, 3. gün İstek 50, 6. gün Zorunlu 30 */ +const days: DaySpent[] = Array.from({ length: 10 }, (_, i) => { + const date = `2026-12-${String(i + 1).padStart(2, '0')}` + const spent = { ...zero } + if (i === 0) spent.needs = 100 + if (i === 2) spent.wants = 50 + if (i === 5) spent.needs = 30 + return { date, spent } +}) +const weeks = [ + { index: 1, start_date: '2026-12-01', last_date: '2026-12-06', days: 6 }, + { index: 2, start_date: '2026-12-07', last_date: '2026-12-10', days: 4 }, +] + +describe('chart series', () => { + it('daily: cumulative spending stops today, plan spreads evenly', () => { + const s = dailySeries(days, 1_000, '2026-12-03') + expect(s.map((p) => p.plan)).toEqual([100, 200, 300, 400, 500, 600, 700, 800, 900, 1_000]) + expect(s.map((p) => p.spent)).toEqual([100, 100, 150, null, null, null, null, null, null, null]) + expect(s[2]).toMatchObject({ label: '03.12', needs: 100, wants: 50, culture: 0 }) + expect(s[3].needs).toBeNull() + }) + + it('daily: negative spendable is drawn as zero plan', () => { + expect(dailySeries(days, -500, '2026-12-10')[9].plan).toBe(0) + }) + + it('weekly: week end values, current week up to today', () => { + const s = weeklySeries(days, weeks, 1_000, '2026-12-08') + expect(s[0]).toMatchObject({ label: '1. hf', plan: 600, spent: 180, needs: 130, wants: 50 }) + expect(s[1]).toMatchObject({ label: '2. hf', plan: 1_000, spent: 180 }) + // Henüz başlamamış hafta + const early = weeklySeries(days, weeks, 1_000, '2026-12-05') + expect(early[0].spent).toBe(150) // 5. güne kadar (6. günün 30'u yok) + expect(early[1]).toMatchObject({ plan: 1_000, spent: null, needs: null }) + // Dönem bitti → son günün değeri + expect(weeklySeries(days, weeks, 1_000, '2027-01-15')[1].spent).toBe(180) + }) + + it('monthly: one point per period with group breakdown', () => { + const periods = [ + { name: 'Kasım 2026', spendable: 2_000, spent: 900, spent_by_group: { ...zero, needs: 900 } }, + { + name: 'Aralık 2026', + spendable: 2_500, + spent: 300, + spent_by_group: { ...zero, culture: 300 }, + }, + ] as PeriodStat[] + expect(monthlySeries(periods)).toEqual([ + { + label: 'Kasım 2026', + plan: 2_000, + spent: 900, + needs: 900, + wants: 0, + culture: 0, + unexpected: 0, + }, + { + label: 'Aralık 2026', + plan: 2_500, + spent: 300, + needs: 0, + wants: 0, + culture: 300, + unexpected: 0, + }, + ]) + }) + + it('last point with data', () => { + const s = dailySeries(days, 1_000, '2026-12-03') + expect(lastPoint(s)?.label).toBe('03.12') + expect(lastPoint(dailySeries(days, 1_000, '2026-11-30'))).toBeNull() + }) +}) diff --git a/frontend/src/lib/charts.ts b/frontend/src/lib/charts.ts new file mode 100644 index 0000000..6e7d97d --- /dev/null +++ b/frontend/src/lib/charts.ts @@ -0,0 +1,91 @@ +/** + * Panel grafikleri için seriler (saf hesaplar). Tutarlar kuruş. + * + * - **Günlük / Haftalık** (bu dönem, kümülatif): harcanabilir tutar günlere eşit dağıtılır + * ("bu güne kadar en fazla bu kadar harcanmalıydı"); harcama bugüne kadar toplanır, + * gelecek günlerde `null` (çizgi bugün biter). + * - **Aylık** (son dönemler, kümülatif değil): her dönemin harcanabilir ve harcananı. + */ +import type { DaySpent } from '@/types/DaySpent' +import type { GroupAmounts } from '@/types/GroupAmounts' +import type { PeriodStat } from '@/types/PeriodStat' +import type { PeriodWeek } from '@/types/PeriodWeek' +import { formatDayMonth } from './format' +import { GROUPS, type GroupCode } from './groups' + +export type ChartPoint = { + label: string + /** Harcanabilir (kümülatif ya da dönem toplamı) */ + plan: number + /** Toplam harcanan; henüz gelmemiş gün/hafta için `null` */ + spent: number | null +} & Record + +export type ChartRange = 'daily' | 'weekly' | 'monthly' + +const codes = GROUPS.map((g) => g.code) +const total = (a: GroupAmounts) => codes.reduce((s, c) => s + a[c], 0) + +/** `spendable × gün / toplam gün` (kuruş, yuvarlanmış); eksiyse 0'dan aşağı inmez */ +const planUpTo = (spendable: number, day: number, days: number) => + days > 0 ? Math.round((Math.max(spendable, 0) * day) / days) : 0 + +export function dailySeries(days: DaySpent[], spendable: number, today: string): ChartPoint[] { + const run: Record = { needs: 0, wants: 0, culture: 0, unexpected: 0 } + return days.map((d, i) => { + const past = d.date <= today + for (const c of codes) run[c] += d.spent[c] + const groups = Object.fromEntries(codes.map((c) => [c, past ? run[c] : null])) as Record< + GroupCode, + number | null + > + return { + label: formatDayMonth(d.date), + plan: planUpTo(spendable, i + 1, days.length), + spent: past ? codes.reduce((s, c) => s + run[c], 0) : null, + ...groups, + } + }) +} + +/** Günlük serinin her haftanın son günündeki değeri; başlamamış hafta `null` */ +export function weeklySeries( + days: DaySpent[], + weeks: readonly PeriodWeek[], + spendable: number, + today: string, +): ChartPoint[] { + const daily = dailySeries(days, spendable, today) + return weeks.map((w) => { + const lastIdx = days.findIndex((d) => d.date === w.last_date) + const started = w.start_date <= today + // İçinde bulunulan hafta: bugüne kadarki değer + const idx = started && w.last_date > today ? days.findIndex((d) => d.date === today) : lastIdx + const p = daily[idx >= 0 ? idx : lastIdx] + const plan = daily[lastIdx]?.plan ?? 0 + if (!p || !started) { + return { label: `${w.index}. hf`, plan, spent: null, ...nulls() } + } + return { ...p, label: `${w.index}. hf`, plan } + }) +} + +export function monthlySeries(periods: PeriodStat[]): ChartPoint[] { + return periods.map((p) => ({ + label: p.name, + plan: p.spendable, + spent: total(p.spent_by_group), + ...p.spent_by_group, + })) +} + +const nulls = () => + Object.fromEntries(codes.map((c) => [c, null])) as Record + +/** Ekran okuyucu ve grafiğin altı için kısa özet: son değerli nokta */ +export function lastPoint(points: ChartPoint[]): ChartPoint | null { + for (let i = points.length - 1; i >= 0; i--) { + if (points[i].spent !== null) return points[i] + } + return null +} diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index 1e04459..8114391 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + formatCompact, formatWeekday, formatDate, formatMoney, @@ -35,6 +36,12 @@ describe('diğer biçimler', () => { expect(formatPercent(0.123)).toBe('%12,3') }) + it('grafik ekseni için kısa tutar', () => { + expect(formatCompact(2_700_000)).toBe('27\u00a0B') // bölünmez boşluk + expect(formatCompact(150_000_000)).toBe('1,5\u00a0Mn') + expect(formatCompact(95_000)).toBe('950') + }) + it('tarih GG.AA.YYYY', () => { expect(formatDate('2026-12-09')).toBe('09.12.2026') expect(formatDate(new Date(2027, 0, 1))).toBe('01.01.2027') diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 8ad7dce..26b1832 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -34,6 +34,13 @@ export function formatNumber(value: number, decimals = 2): string { return numberFormat(decimals).format(value) } +const compact = new Intl.NumberFormat('tr-TR', { notation: 'compact', maximumFractionDigits: 1 }) + +/** Grafik ekseni için kısa tutar (kuruş → TL): `2.700.000` → `27 B`, `150.000.000` → `1,5 Mn` */ +export function formatCompact(kurus: number): string { + return compact.format(kurus / 100) +} + /** Kuruş → `1.250,00 ₺`. `signed: true` ise pozitiflere `+` eklenir. */ export function formatMoney( kurus: number, diff --git a/frontend/src/lib/periods.ts b/frontend/src/lib/periods.ts index 8626a47..738db92 100644 --- a/frontend/src/lib/periods.ts +++ b/frontend/src/lib/periods.ts @@ -6,6 +6,7 @@ */ import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import type { BudgetSuggestion } from '@/types/BudgetSuggestion' +import type { DaySpent } from '@/types/DaySpent' import type { CopyPreviousResult } from '@/types/CopyPreviousResult' import type { GroupAmounts } from '@/types/GroupAmounts' import type { GroupBudget } from '@/types/GroupBudget' @@ -148,6 +149,14 @@ export function useStats(limit = 12) { }) } +/** Dönemin her günü: 4 grubun harcaması (grafik) */ +export function useDaily(periodId: string) { + return useQuery({ + queryKey: queryKeys.daily(periodId), + queryFn: () => api.get(`/periods/${periodId}/daily`), + }) +} + // ---------- Özet ve bütçe ---------- export function useSummary(periodId: string) { diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts index adc21ba..dc50c06 100644 --- a/frontend/src/lib/query.ts +++ b/frontend/src/lib/query.ts @@ -22,6 +22,8 @@ export const queryKeys = { planItems: (id: string) => ['period', id, 'plan-items'] as const, summary: (id: string) => ['period', id, 'summary'] as const, review: (id: string) => ['period', id, 'review'] as const, + /** Panel grafiği: günlük harcama (kayıt değişince dönem önekiyle yenilenir) */ + daily: (id: string) => ['period', id, 'daily'] as const, budget: (id: string) => ['period', id, 'budget'] as const, suggestion: (id: string, method: string) => ['period', id, 'suggestion', method] as const, notes: (id: string, context: string) => ['period', id, 'notes', context] as const, diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index dae1ab4..f81b98b 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -381,6 +381,29 @@ export const t = { `Açık dönem yok. Son dönem (${name}) gösteriliyor; değiştirilemez.`, }, + charts: { + title: 'Grafikler', + daily: 'Günlük', + weekly: 'Haftalık', + monthly: 'Aylık', + planTitle: 'Harcanabilir ve gerçekleşen (birikerek)', + planTitleMonthly: 'Dönemlere göre harcanabilir ve harcanan', + groupsTitle: 'Gruplara göre harcama', + togglePlan: 'Harcanabilir çizgisi', + plan: 'Harcanabilir', + spent: 'Harcanan', + untilNow: 'Bugüne kadar', + cumulativeSummary: (spent: string, plan: string, weekly: boolean) => + weekly + ? `Şimdiye kadar ${spent} harcandı; plana göre bu hafta sonuna kadar en fazla ${plan}.` + : `Bugüne kadar ${spent} harcandı; plana göre bugüne kadar en fazla ${plan}.`, + monthlySummary: (name: string, plan: string, spent: string) => + `${name}: harcanabilir ${plan}, harcanan ${spent}.`, + groupSummary: (when: string, group: string, amount: string) => + `${when} en çok harcanan grup: ${group} (${amount}).`, + noData: 'Henüz harcama yok.', + }, + periods: { picker: 'Dönem', status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' }, diff --git a/frontend/src/types/PeriodStat.ts b/frontend/src/types/PeriodStat.ts index cda5bc5..5743b8f 100644 --- a/frontend/src/types/PeriodStat.ts +++ b/frontend/src/types/PeriodStat.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GroupAmounts } from "./GroupAmounts"; import type { PeriodStatus } from "./PeriodStatus"; export type PeriodStat = { id: string, name: string, start_date: string, @@ -6,6 +7,10 @@ export type PeriodStat = { id: string, name: string, start_date: string, * Son gün, dahil */ last_date: string, status: PeriodStatus, spendable: number, spent: number, +/** + * Harcanan, gruplara göre + */ +spent_by_group: GroupAmounts, /** * Kapanmamışsa `null` */