diff --git a/CLAUDE.md b/CLAUDE.md index 2dd2808..aac47a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ scripts/ seed, reset, backup - Her sorgu `household_id` ile filtrelenir (ortak middleware). - Hesaplanan değerler saklanmaz; aggregation ile hesaplanır. - Format: `1.250,00 ₺`, tarih `GG.AA.YYYY`; tümü `lib/format.ts` üzerinden. -- Haftalar Pazartesi başlar; kısmi haftalar gün sayısına göre orantılanır. +- Haftalar dönemin günlerine göre: 1–8, 9–15, 16–22, 23–son gün (28–31 günlük dönem = 4 hafta; genel: `max(1, gün/7)` hafta, ilk 8 gün, ara 7, son kalan). Bütçe haftalara gün sayısıyla orantılanır. Tek kaynak: `calendar::weeks`. - Hatalar kullanıcıya Türkçe ve sade; teknik detay yalnızca logda. - Commit mesajları Türkçe: `feat(defter): haftalık giriş listesi eklendi` @@ -73,7 +73,7 @@ npm run db:restore -- [--force] - Geliştirmede tasarım önizlemesi: `/dev/tasarim`. - Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`). - Dönem verisi: `lib/periods.ts` (Query kancaları), tarih `lib/period-dates.ts` (arayüz son günü dahil gösterir, backend `end_date` hariç), tutar alanı `MoneyInput` + `moneyText`/`toKurus` (`lib/money-schema.ts`). İlk dönem sihirbazı `/plan/donem-ac?adim=`. Plan düzenleme: taslak → `diffPlan` → `useSavePlan`; haftalık önizleme `lib/budget-preview.ts` (backend hesabıyla aynı tutulmalı). -- Defter: `/defter?hafta=N`, `features/ledger/` (hafta seçici, hızlı giriş), saf hesaplar `lib/ledger.ts`, kancalar `lib/entries.ts` + `lib/catalog.ts`. Kayıt değişince `queryKeys.period(id)` yenilenir (özet/bütçe `spent` dahil). +- Defter: `/defter` her açılışta bugünün haftası (hafta adrese yazılmaz), `features/ledger/` (hafta seçici, hızlı giriş), saf hesaplar `lib/ledger.ts`, kancalar `lib/entries.ts` + `lib/catalog.ts`. Kayıt değişince `queryKeys.period(id)` yenilenir (özet/bütçe `spent` dahil). - Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`. - API örnekleri: `backend/api.http` - Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır. diff --git a/KAKEIBO_PROMPT.md b/KAKEIBO_PROMPT.md index f7549be..609287f 100644 --- a/KAKEIBO_PROMPT.md +++ b/KAKEIBO_PROMPT.md @@ -162,7 +162,7 @@ kakeibo/ - Dönem **başlangıç ve bitiş tarihiyle** açılır. Örnek: `01.12.2026` – `01.01.2027`. - Aynı anda yalnızca **bir açık dönem** olur; girişler yalnızca açık döneme yapılır. -- **Haftalar:** Pazartesi başlangıçlı. Dönemin başı/sonundaki kısmi haftalar ayrı hafta sayılır ve bütçeleri gün sayısına göre orantılanır. +- **Haftalar:** (kullanıcı kararı 2026-11-13, eski "Pazartesi başlangıçlı" kuralının yerine) dönemin 1–8. günü 1. hafta, 9–15 2., 16–22 3., 23–son gün 4. hafta. Bütçeleri gün sayısına göre orantılanır. - **Dönem kapatma** (sihirbaz adımları): 1. Özet ve planlanan/gerçekleşen karşılaştırması gösterilir. 2. Kalan bakiye ve borç hesaplanır. diff --git a/backend/src/services/calendar.rs b/backend/src/services/calendar.rs index e01d456..b651ca6 100644 --- a/backend/src/services/calendar.rs +++ b/backend/src/services/calendar.rs @@ -52,20 +52,35 @@ pub struct Week { pub days: u32, } -/// Dönemi Pazartesi başlangıçlı haftalara böler. -/// Baştaki ve sondaki kısmi haftalar ayrı hafta sayılır (KAKEIBO_PROMPT §6). +/// Dönemi **ayın günlerine göre** haftalara böler (kullanıcı kararı, 2026-11-13): +/// 1–8 → 1. hafta, 9–15 → 2., 16–22 → 3., 23–son gün → 4. hafta. +/// +/// Gün numarası dönem başından sayılır (dönem 15'inde başlıyorsa 15–22 = 1. hafta). +/// Genel kural: hafta sayısı `max(1, gün / 7)`; ilk hafta 8 gün, aradakiler 7 gün, +/// son hafta kalan günler (6–12 gün). 28–31 günlük dönem → daima 4 hafta. +/// Bütçe haftalara gün sayısıyla orantılı dağıtılır (`days`). pub fn weeks(start: Date, end_exclusive: Date) -> Vec { - let mut out = Vec::new(); + let total = days_between(start, end_exclusive); + if total <= 0 { + return Vec::new(); + } + let count = (total / 7).max(1); + let mut out = Vec::with_capacity(count as usize); let mut cursor = start; - while cursor < end_exclusive { - // Bir sonraki Pazartesi (ya da dönem sonu, hangisi önce gelirse) - let to_monday = 7 - i64::from(cursor.weekday().number_days_from_monday()); - let next = (cursor + Duration::days(to_monday)).min(end_exclusive); + for i in 0..count { + let len = if i == count - 1 { + days_between(cursor, end_exclusive) + } else if i == 0 { + 8 + } else { + 7 + }; + let next = cursor + Duration::days(len); out.push(Week { - index: out.len() as u32 + 1, + index: i as u32 + 1, start: cursor, last: next - Duration::days(1), - days: days_between(cursor, next) as u32, + days: len as u32, }); cursor = next; } @@ -123,37 +138,69 @@ mod tests { assert!(parse_date("2028-02-29").is_some(), "artık yıl"); } + fn summary(w: &[Week]) -> Vec<(u32, String, String, u32)> { + w.iter() + .map(|w| (w.index, format_date(w.start), format_date(w.last), w.days)) + .collect() + } + #[test] - fn december_2026_weeks() { - // 1 Aralık 2026 Salı → ilk hafta 6 gün (Sal–Paz), son hafta 4 gün (Pzt–Per) - let w = weeks(d("2026-12-01"), d("2027-01-01")); - let summary: Vec<(String, String, u32)> = w - .iter() - .map(|w| (format_date(w.start), format_date(w.last), w.days)) - .collect(); + fn month_weeks_follow_day_of_month() { + // Kasım 2026 (1'i Pazar): 1–8, 9–15, 16–22, 23–30 → 4 hafta + let w = weeks(d("2026-11-01"), d("2026-12-01")); assert_eq!( - summary, + summary(&w), vec![ - ("2026-12-01".into(), "2026-12-06".into(), 6), - ("2026-12-07".into(), "2026-12-13".into(), 7), - ("2026-12-14".into(), "2026-12-20".into(), 7), - ("2026-12-21".into(), "2026-12-27".into(), 7), - ("2026-12-28".into(), "2026-12-31".into(), 4), + (1, "2026-11-01".into(), "2026-11-08".into(), 8), + (2, "2026-11-09".into(), "2026-11-15".into(), 7), + (3, "2026-11-16".into(), "2026-11-22".into(), 7), + (4, "2026-11-23".into(), "2026-11-30".into(), 8), ] ); - assert_eq!(w.iter().map(|w| w.days).sum::(), 31); - assert_eq!(w.last().unwrap().index, 5); + // 31 gün → son hafta 23–31 (9 gün); Şubat → 23–28 (6 gün); artık yıl → 7 gün + for (start, end, last_days) in [ + ("2026-12-01", "2027-01-01", 9), + ("2027-02-01", "2027-03-01", 6), + ("2028-02-01", "2028-03-01", 7), + ] { + let w = weeks(d(start), d(end)); + assert_eq!(w.len(), 4, "{start}"); + assert_eq!(w[3].start.day(), 23, "{start}"); + assert_eq!(w[3].days, last_days, "{start}"); + assert_eq!( + w.iter().map(|w| i64::from(w.days)).sum::(), + days_between(d(start), d(end)) + ); + } } #[test] fn weeks_edge_cases() { - // Pazartesi başlayan, tam 2 hafta - let w = weeks(d("2026-11-30"), d("2026-12-14")); - assert_eq!(w.len(), 2); - assert!(w.iter().all(|w| w.days == 7)); - // Tek gün (Pazar) + // Ay ortasında başlayan dönem: günler dönem başından sayılır + let w = weeks(d("2026-12-15"), d("2027-01-15")); + assert_eq!( + summary(&w) + .iter() + .map(|x| (x.1.as_str(), x.3)) + .collect::>(), + vec![ + ("2026-12-15", 8), + ("2026-12-23", 7), + ("2026-12-30", 7), + ("2027-01-06", 9) + ] + ); + // Kısa dönemler: 13 gün → tek hafta, 14 gün → 8 + 6 + assert_eq!(summary(&weeks(d("2026-12-01"), d("2026-12-14"))).len(), 1); + let w = weeks(d("2026-12-01"), d("2026-12-15")); + assert_eq!((w[0].days, w[1].days), (8, 6)); + // Tek gün let w = weeks(d("2026-12-06"), d("2026-12-07")); assert_eq!((w.len(), w[0].days), (1, 1)); + // Uzun dönem (62 gün) → 8 hafta, son hafta ≤ 12 gün + let w = weeks(d("2026-11-01"), d("2027-01-02")); + assert_eq!(w.len(), 8); + assert!(w.iter().all(|w| (6..=12).contains(&w.days))); // Boş aralık assert!(weeks(d("2026-12-07"), d("2026-12-07")).is_empty()); } diff --git a/backend/tests/budget.rs b/backend/tests/budget.rs index c9afded..de585f6 100644 --- a/backend/tests/budget.rs +++ b/backend/tests/budget.rs @@ -139,7 +139,7 @@ async fn monthly_budget_is_split_into_weeks_by_days() { // Başta hepsi 0 let (_, b) = app.get(&url, &s).await; assert_eq!(b.as_array().unwrap().len(), 4); - assert_eq!(week_amounts(group(&b, "needs")), vec![0; 5]); + assert_eq!(week_amounts(group(&b, "needs")), vec![0; 4]); let (st, b) = app .put( @@ -151,12 +151,9 @@ async fn monthly_budget_is_split_into_weeks_by_days() { assert_eq!(st, StatusCode::OK, "{b}"); let needs = group(&b, "needs"); assert_eq!(needs["monthly"], 100_000); - // Aralık: 6,7,7,7,4 gün → toplam tam 100.000 - assert_eq!( - week_amounts(needs), - vec![19_355, 22_581, 22_581, 22_580, 12_903] - ); - assert_eq!(week_amounts(group(&b, "culture")), vec![6, 7, 7, 7, 4]); + // Aralık: 1–8, 9–15, 16–22, 23–31 → 8,7,7,9 gün; toplam tam 100.000 + assert_eq!(week_amounts(needs), vec![25_806, 22_581, 22_581, 29_032]); + assert_eq!(week_amounts(group(&b, "culture")), vec![8, 7, 7, 9]); for g in b.as_array().unwrap() { assert_eq!( week_amounts(g).iter().sum::(), @@ -207,20 +204,17 @@ async fn manual_week_override_redistributes_the_rest() { .await; let weeks_url = format!("/periods/{pid}/budget/weeks"); - // 2. hafta (7–13 Ara) elle 50.000 → kalan 50.000 diğer haftalara (6+7+7+4 = 24 gün) + // 2. hafta (9–15 Ara) elle 50.000 → kalan 50.000 diğer haftalara (8+7+9 = 24 gün) let (st, b) = app .put( &weeks_url, &s, - json!({ "group": "needs", "week_start": "2026-12-07", "amount": 50_000 }), + json!({ "group": "needs", "week_start": "2026-12-09", "amount": 50_000 }), ) .await; assert_eq!(st, StatusCode::OK, "{b}"); let needs = group(&b, "needs"); - assert_eq!( - week_amounts(needs), - vec![12_500, 50_000, 14_584, 14_583, 8_333] - ); + assert_eq!(week_amounts(needs), vec![16_667, 50_000, 14_583, 18_750]); assert_eq!(week_amounts(needs).iter().sum::(), 100_000); let manual: Vec = needs["weeks"] .as_array() @@ -228,7 +222,7 @@ async fn manual_week_override_redistributes_the_rest() { .iter() .map(|w| w["is_manual"].as_bool().unwrap()) .collect(); - assert_eq!(manual, vec![false, true, false, false, false]); + assert_eq!(manual, vec![false, true, false, false]); assert_eq!(needs["weekly_over"], false); // Aylık tutar değişince elle girilen hafta korunur @@ -247,25 +241,25 @@ async fn manual_week_override_redistributes_the_rest() { .put( &weeks_url, &s, - json!({ "group": "needs", "week_start": "2026-12-14", "amount": 30_000 }), + json!({ "group": "needs", "week_start": "2026-12-16", "amount": 30_000 }), ) .await; let needs = group(&b, "needs"); assert_eq!(needs["weekly_over"], true); - assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0, 0]); + assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0]); // null → otomatiğe dön app.put( &weeks_url, &s, - json!({ "group": "needs", "week_start": "2026-12-14", "amount": null }), + json!({ "group": "needs", "week_start": "2026-12-16", "amount": null }), ) .await; let (_, b) = app .put( &weeks_url, &s, - json!({ "group": "needs", "week_start": "2026-12-07", "amount": null }), + json!({ "group": "needs", "week_start": "2026-12-09", "amount": null }), ) .await; assert_eq!(week_amounts(group(&b, "needs")).iter().sum::(), 74_000); @@ -372,7 +366,7 @@ async fn budget_closed_period_and_isolation() { app.put( &format!("/periods/{pid}/budget/weeks"), &b, - json!({ "group": "needs", "week_start": "2026-12-07", "amount": 1 }), + json!({ "group": "needs", "week_start": "2026-12-09", "amount": 1 }), ) .await .0, diff --git a/backend/tests/entries.rs b/backend/tests/entries.rs index c90fb1b..d92a2c1 100644 --- a/backend/tests/entries.rs +++ b/backend/tests/entries.rs @@ -495,11 +495,11 @@ async fn actual_spending_in_summary_and_budget() { ) .await; - // Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31 + // Aralık 2026 haftaları: 1–8 · 9–15 · 16–22 · 23–31 for (date, amount, cat) in [ ("2026-12-01", 1_000, &needs), - ("2026-12-06", 2_000, &needs), // 1. haftanın son günü - ("2026-12-07", 500, &needs), // 2. haftanın ilk günü + ("2026-12-08", 2_000, &needs), // 1. haftanın son günü + ("2026-12-09", 500, &needs), // 2. haftanın ilk günü ("2026-12-31", 300, &wants), // dönemin son günü ] { let (st, b) = add( @@ -516,7 +516,7 @@ async fn actual_spending_in_summary_and_budget() { &app, &s, &pid, - json!({ "date": "2026-12-08", "amount": 400, "category_id": needs }), + json!({ "date": "2026-12-10", "amount": 400, "category_id": needs }), ) .await; app.patch( @@ -530,7 +530,7 @@ async fn actual_spending_in_summary_and_budget() { &app, &s, &pid, - json!({ "date": "2026-12-15", "amount": 700, "category_id": culture }), + json!({ "date": "2026-12-16", "amount": 700, "category_id": culture }), ) .await; let gone_id = gone["id"].as_str().unwrap(); @@ -546,18 +546,15 @@ async fn actual_spending_in_summary_and_budget() { let (st, b) = app.get(&format!("/periods/{pid}/budget"), &s).await; assert_eq!(st, StatusCode::OK); - assert_eq!( - spent_by_week(&b, "needs"), - (3_500, vec![3_000, 500, 0, 0, 0]) - ); - assert_eq!(spent_by_week(&b, "wants"), (700, vec![0, 400, 0, 0, 300])); - assert_eq!(spent_by_week(&b, "culture"), (0, vec![0; 5])); + assert_eq!(spent_by_week(&b, "needs"), (3_500, vec![3_000, 500, 0, 0])); + assert_eq!(spent_by_week(&b, "wants"), (700, vec![0, 400, 0, 300])); + assert_eq!(spent_by_week(&b, "culture"), (0, vec![0; 4])); // Geri al → yeniden sayılır app.post(&format!("/entries/{gone_id}/restore"), &s, json!({})) .await; let (_, b) = app.get(&format!("/periods/{pid}/budget"), &s).await; - assert_eq!(spent_by_week(&b, "culture"), (700, vec![0, 0, 700, 0, 0])); + assert_eq!(spent_by_week(&b, "culture"), (700, vec![0, 0, 700, 0])); // Harcanabilir aşılırsa kalan negatif add( diff --git a/backend/tests/periods.rs b/backend/tests/periods.rs index 98e9ce7..fd8ecbc 100644 --- a/backend/tests/periods.rs +++ b/backend/tests/periods.rs @@ -40,11 +40,7 @@ async fn open_period_returns_weeks_and_default_name() { .iter() .map(|w| w["days"].as_i64().unwrap()) .collect(); - assert_eq!( - days, - vec![6, 7, 7, 7, 4], - "Pazartesi başlangıçlı, kısmi haftalar ayrı" - ); + assert_eq!(days, vec![8, 7, 7, 9], "1–8, 9–15, 16–22, 23–31"); let (_, cur) = app.get("/periods/current", &s).await; assert_eq!(cur["id"], p["id"]); diff --git a/backend/tests/review.rs b/backend/tests/review.rs index bc7dd80..8fbb742 100644 --- a/backend/tests/review.rs +++ b/backend/tests/review.rs @@ -95,11 +95,11 @@ async fn four_questions_and_breakdowns() { let culture = category(&app, &s, "culture", 0).await; // Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31 spend(&app, &s, &pid, "2026-12-01", 100_000, &market).await; - spend(&app, &s, &pid, "2026-12-08", 50_000, &market).await; - spend(&app, &s, &pid, "2026-12-08", 300_000, &rent).await; - spend(&app, &s, &pid, "2026-12-15", 40_000, &wants).await; + spend(&app, &s, &pid, "2026-12-09", 50_000, &market).await; + spend(&app, &s, &pid, "2026-12-09", 300_000, &rent).await; + spend(&app, &s, &pid, "2026-12-16", 40_000, &wants).await; spend(&app, &s, &pid, "2026-12-31", 10_000, &culture).await; - let gone = spend(&app, &s, &pid, "2026-12-15", 99_999, &wants).await; + let gone = spend(&app, &s, &pid, "2026-12-16", 99_999, &wants).await; app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s) .await; @@ -145,13 +145,13 @@ async fn four_questions_and_breakdowns() { // Haftalar: 4 grubun toplamı; planlananların toplamı aylık bütçelerin toplamı assert_eq!( column(&r["weeks"], "spent"), - vec![100_000, 350_000, 40_000, 0, 10_000] + vec![100_000, 350_000, 40_000, 10_000] ); assert_eq!( column(&r["weeks"], "planned").iter().sum::(), 1_800_000 ); - assert_eq!(r["weeks"][4]["last_date"], "2026-12-31"); + assert_eq!(r["weeks"][3]["last_date"], "2026-12-31"); app.cleanup().await; } @@ -176,7 +176,7 @@ async fn overspending_gives_negative_savings() { ); // Hiç bütçe yoksa: gruplar 0 planlı, haftalar yine dönem takviminden assert_eq!(column(&r["groups"], "planned"), vec![0; 4]); - assert_eq!(r["weeks"].as_array().unwrap().len(), 5); + assert_eq!(r["weeks"].as_array().unwrap().len(), 4); app.cleanup().await; } diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 256113e..b00264f 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -92,7 +92,7 @@ Renkler ve ikonlar frontend tasarım token'larında. - **Bitiş tarihi hariç:** `01.12.2026 – 01.01.2027` → 1–31 Aralık. Dönemler arka arkaya boşluksuz ve çakışmasız eklenir. - Hanede en fazla **bir** `open` dönem (servis kontrolü + kısmi benzersiz indeks). - **Yeniden açma (karar 2026-11-12, "düzeltme modu"):** sahip kapalı dönemi açınca durum `correcting` olur; yazılabilir ama "açık dönem" sayılmaz → yeni dönem açık kalabilir (tek `open` kuralı korunur). Mevcut devir "yeniden kapat"a kadar yerinde kalır; yeniden kapatınca eskisi soft delete, yenisi hesaplanır. Ardından kapanmış dönem varsa açılamaz (zincirleme devirler bozulmasın). -- **Haftalar saklanmaz.** `start_date`/`end_date`'ten hesaplanır (Pazartesi başlangıçlı; ilk/son kısmi haftalar ayrı). +- **Haftalar saklanmaz.** `start_date`/`end_date`'ten hesaplanır (dönemin 1–8, 9–15, 16–22, 23–son günleri; `calendar::weeks`). Elle girilmiş haftalık bütçe `week_start` ile eşleşir; eşleşmeyen eski kayıt yok sayılır. - **Arşiv (`archived`, karar 2026-11-13):** ekstre içe aktarımında dönemi olmayan aylar için takvim ayı kadar açılır (var olan dönemlerle çakışmayacak şekilde kırpılır). Yazılabilir (kayıt düzeltme), ama kapatılmaz, devir üretmez; devir zincirinde (yeni dönemin önceki dönemi, kapatmada sonraki dönem, yeniden açma engeli) **atlanır**. Bütçe önerileri ("önceki dönem", "son 3 ay") kapalı **ve** arşiv dönemleri kullanır. ### 3.5 `categories` — Alt kategoriler diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 7b81fb0..2b34ca4 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,10 +1,11 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 9 – Adım 5:** JSON tam hane yedeği. Önce best practice analizi + tasarımı kullanıcıya sun (kullanıcı "üzmeyen, işi kolaylaştıran" istedi): sürümlü biçim (uygulama sürümü + şema/migration sürümü + dışa aktarma zamanı + hane adı), içerik (dönemler, plan, bütçe, harcamalar, devirler, notlar, kategoriler, ödeme türleri, varlıklar, işlemler, manuel kurlar, ekstre kuralları; kullanıcı/şifre/davet **yok**), geri yükleme yalnız sahip, önizleme (sayılar, tarih aralığı, kaynak hane), **sunucuda otomatik geri dönüş noktası** + "geri yüklemeyi geri al" (süre? örn. 7 gün / son 3), başka haneden taşıma (kimlikler yeniden üretilir mi korunur mu — iç referanslar!), transaction, büyük dosya sınırı. Sonra Adım 6: aylık rapor (Claude tasarlar). +**Faz 9 – Adım 5a:** JSON yedeği backend. Tasarım kullanıcıya sunuldu ve onaylandı ("5a'dan devam"): `kakeibo-yedek--.json` (format/şema sürümü, zaman, hane, sayılar, SHA-256), içerik hane verisi (kullanıcı/şifre/davet/otomatik kur/silinmiş yok), dışa aktarma her üye, geri yükleme yalnız sahip; önizleme → tam değiştirme (birleştirme yok) tek transaction; geri yüklemeden önce sıkıştırılmış geri dönüş noktası (`flate2`), son 3 / 30 gün, "geri al" da nokta oluşturur; aynı hane → kimlikler korunur, başka hane → yeniden üretilir + iç referanslar çevrilir, `created_by` yoksa geri yükleyen; eski biçim yükseltilir, yeni biçim reddedilir; 20 MB. 5b: arayüz + "son yedek X gün önce" hatırlatması. **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 +- **Hafta kuralı değişti (kullanıcı, 2026-11-13):** Pazartesi başlangıçlı haftalar yerine dönemin 1–8, 9–15, 16–22, 23–son günleri (ay = 4 hafta). Kasım 2026'da 6 hafta (01.11 tek gün) çıkması üzerine. Genel: `max(1, gün/7)` hafta, ilk 8 gün, ara 7, son kalan (6–12). Defter her açılışta bugünün haftası; hafta artık adrese yazılmaz (sekme geri yüklemede eski hafta açılıyordu). - **Ekstre kararları (kullanıcı, 2026-11-13):** biçim Excel (.xlsx/.xls) + CSV (kullanıcı tam bilmiyor; ilk gerçek ekstreyle denenecek), hesaba gelenler → **gelir** (plan kalemi), arşiv dönemi **takvim ayı**, iadeler **aynı kategoriden düşülür** (eksi tutarlı kayıt). - **Dikkat (2026-11-13):** Claude'un hızlı ardışık `backend/src` değişiklikleri (bilerek bozup geri alma, toplu düzenleme) kullanıcının `npm run dev` içindeki `cargo watch`'ını backend kapalı halde bırakabiliyor (giriş: "Beklenmeyen hata", 5173 → 502). Böyle denemelerden sonra `curl localhost:8080/api/health`; kapalıysa `touch backend/src/main.rs` (cargo watch yeniden başlatır). Arayüz artık 502/503/504'te "Sunucu şu an yanıt vermiyor" der. - **Faz 9 kararları (kullanıcı, 2026-11-13):** (1) Aylık rapor biçimini **Claude tasarlar**: müşteri gözüyle ne yaptım / ne yapılabilirdi / ne yapılmalıydı. (2) JSON yedek: Claude best practice analizi yapıp kullanıcıyı üzmeyen çözüm üretir (öneri: sunucuda otomatik geri dönüş noktası + "geri al", önizleme, sahip). (3) **Tüm kredi kartı ve nakit/banka hesabı ekstreleri** içe aktarılabilsin; amaç **yıl başından beri** veriyi toplayıp sonraki aylara referans; grup/kategori eşleştirmesi kullanıcıya bırakılabilir → ayrı adım (Faz 9 Adım 4), geçmiş dönemler gerekir. @@ -140,3 +141,4 @@ - **Adım 3:** CSV dışa/içe aktarma. Backend `services/csv_io.rs` (saf: yazma BOM/`;`/CRLF/`1250,50`/GG.AA.YYYY; okuma: BOM, `;`/`,`/sekme otomatik, tırnak, satır numarası, `norm` başlık eşleme (Türkçe İ/ı), `parse_money` (1.250,50 · 1250.50 · 1.000=bin), `parse_e6`, `parse_date_any`), `services/transfer.rs` (harcama/plan/varlık: dışa aktar; içe aktar `commit=false` deneme → rapor {total, valid, duplicates, imported, errors[(satır, neden)], preview}; tekrar sayacı `Existing` (dosyada 2, DB'de 1 → 1 eklenir); harcamada dönem tarihten, kapalı/yok → satır hatası, kategori adla (+grup), aynı ad 2 grupta → hata; plan `plan::create` ile; varlıkta tarih+dosya sırasına göre eldekinden fazla satış simülasyonu, yazma `asset::create_tx`). `Group::label()`. Rotalar `routes/transfer.rs` (`/export/*.csv`, `POST /import/{tür}` 5 MB). `tests/transfer.rs` (3) + csv_io birim (4); mutasyon 3/3. Frontend: Ayarlar → Veri (`features/settings/data-section.tsx`: indirme bağlantıları, içe aktarma: tür seç → dosya → deneme raporu → onay), `api.postCsv`, `lib/decode.ts` (UTF-8 fatal → Windows-1254), `lib/transfer.ts`. Backend 164, frontend 125 test; e2e 16 kontrol. - **Adım 4a:** Ekstre backend. `PeriodStatus::Archived` (yazılabilir; kapatılmaz 409; devir zincirinde atlanır: `period::create` önceki, `closing::next_period`, reopen engeli; `budget::previous_closed` kapalı+arşiv), `period::create_archive`. `services/bank_import.rs`: `fold` (Türkçe → ASCII; **"İ".to_lowercase() = i + U+0307** düzeltildi), `rule_key` (ilk 2 anlamlı kelime), `import_key`, `default_kind` (ödeme/virman → yoksay; kartta giriş → iade, hesapta → gelir), `target` (içeren yazılabilir dönem / takvim ayı arşivi (kırpılmış) / gelecek-kapalı engel), `preview`, `commit` (arşivleri açar, harcama/iade insert_many + `import_key`, gelir `plan::create` + `import_key`, kurallar upsert `import_rules`). Entry update: iade eksi kalır. Rotalar `routes/bank_import.rs` (`/bank-import/preview|commit`, 5 MB). `tests/bank_import.rs` (3, devir zinciri dahil); mutasyon 5/5. Frontend: `archived` durum metni, Değerlendirme'de arşiv bilgisi, kapatma sihirbazı arşivi reddeder. Backend 177 test. - **Adım 4b:** Ekstre arayüzü `/ayarlar/ekstre` (`features/statement/`: `page.tsx` Dosya → Eşleştir (başlık satırı otomatik `detectHeaderRow`, sütun tahmini `guessMapping`, kaynak kart/hesap → tutar modu, Borç/Alacak, ödeme türü, profil adı → localStorage `kakeibo.statement.profiles`, ad ile eşleşir; önizlemede tarih yorumlanmış) ; `review.tsx` (tür+kategori, değişiklik aynı `ruleKey`'li elle değiştirilmemiş satırlara yayılır + bildirim, filtreler, sayfalama 50, telefonda kart/masaüstünde sütunlu liste, atlanacaklar soluk, arşiv bildirimi, kategori eksikse aktarma kapalı, sonuçta hata satırları tarih+açıklama ile)). `lib/statement.ts` (Excel seri tarih, `parseAmount` TR/EN/parantez/sondaki eksi, `normalize`, `fold`/`ruleKey` = backend, profiller), `lib/read-sheet.ts` (SheetJS lazy 160 KB gz, `blankrows: true` → satır no Excel ile aynı; CSV kendi ayrıştırıcı + `decodeText`), `lib/bank-import.ts`. Backend: kural önerisine **ilk kelime yedeği** (`suggest`, genel kelimeler hariç). SheetJS yerel paket (`frontend/vendor`, Dockerfile kopyalar; Docker web aşaması doğrulandı). Defter: iade satırı "İade" etiketi, düzenlemede artı gösterilip eksi kaydedilir. Frontend 134, backend 178 test; e2e 25 kontrol. +- **Hafta düzeltmesi (2026-11-13):** `calendar::weeks` yeni kural (testler: Kasım/Aralık/Şubat/artık yıl, ay ortası dönem, 13/14/62 gün). Bütçe/defter/değerlendirme entegrasyon testleri yeni sınırlara göre güncellendi (backend 178). Frontend: `LedgerView` haftayı `useState`'te tutar, `parseWeekParam` kaldırıldı, dönem kısalırsa son haftaya düşer; `budget-preview`/`ledger` testleri yeni Aralık (8,7,7,9) ile (133). E2E 9 kontrol (8091, DB silindi). Dev verisinde elle haftalık bütçe yoktu → taşıma gerekmedi; canlıda varsa eşleşmeyen eski hafta yok sayılır (aylık tutar korunur). diff --git a/frontend/src/features/ledger/page.tsx b/frontend/src/features/ledger/page.tsx index 7393279..530f1ed 100644 --- a/frontend/src/features/ledger/page.tsx +++ b/frontend/src/features/ledger/page.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react' -import { useLocation, useSearchParams } from 'react-router' +import { useLocation } from 'react-router' import { Info } from 'lucide-react' import { PageError, PageHeader, PageLoading } from '@/components/page' import { PeriodPicker } from '@/features/periods/period-picker' @@ -8,7 +8,7 @@ import { Section } from '@/features/planning/plan-read' import { useCategories, usePaymentMethods } from '@/lib/catalog' import { useEntries } from '@/lib/entries' import { toIsoDate } from '@/lib/format' -import { parseWeekParam, weekIndexFor } from '@/lib/ledger' +import { weekIndexFor } from '@/lib/ledger' import { isWritable } from '@/lib/period-select' import { useBudget } from '@/lib/periods' import { t } from '@/lib/strings' @@ -33,11 +33,11 @@ export function LedgerPage() { } /** - * Hafta adreste tutulur (`?hafta=2`): yenileyince / geri gelince aynı hafta açılır. + * Defter her açılışta **bugünün haftasıyla** başlar (dönem geçmişteyse son, gelecekteyse ilk + * hafta); oklarla gezilen hafta yalnızca bu ekranda tutulur, adrese yazılmaz (kullanıcı kararı). * Düzen: hafta seçici → form → haftalık özet → girişler (masaüstünde form solda, diğerleri sağda). */ function LedgerView({ period }: { period: Period }) { - const [params, setParams] = useSearchParams() const [today] = useState(() => toIsoDate(new Date())) const [editing, setEditing] = useState(null) // Panel'deki "Harcama Ekle" → tutar alanına odaklan @@ -45,9 +45,9 @@ function LedgerView({ period }: { period: Period }) { const formRef = useRef(null) const inPeriod = today >= period.start_date && today <= period.last_date const todayIndex = inPeriod ? weekIndexFor(period.weeks, today) : null - const index = - parseWeekParam(params.get('hafta'), period.weeks.length) ?? weekIndexFor(period.weeks, today) - const week = period.weeks[index - 1] + const [index, setIndex] = useState(() => weekIndexFor(period.weeks, today)) + // Dönem tarihleri değişip hafta sayısı azalırsa son haftaya düş + const week = period.weeks[Math.min(index, period.weeks.length) - 1] const readOnly = !isWritable(period) const budget = useBudget(period.id) @@ -57,15 +57,7 @@ function LedgerView({ period }: { period: Period }) { const goTo = (n: number) => { setEditing(null) - setParams( - (prev) => { - const next = new URLSearchParams(prev) // seçili dönem (`donem`) korunur - if (n === todayIndex) next.delete('hafta') - else next.set('hafta', String(n)) - return next - }, - { replace: true }, - ) + setIndex(n) } function edit(e: Entry) { @@ -81,7 +73,7 @@ function LedgerView({ period }: { period: Period }) {
) : budget.data ? ( - + ) : ( )} diff --git a/frontend/src/features/ledger/week-picker.tsx b/frontend/src/features/ledger/week-picker.tsx index 80a37c0..8be17cf 100644 --- a/frontend/src/features/ledger/week-picker.tsx +++ b/frontend/src/features/ledger/week-picker.tsx @@ -7,7 +7,7 @@ import type { PeriodWeek } from '@/types/PeriodWeek' const s = t.ledger -/** ◀ önceki | 2. hafta / 5 · 07.12 – 13.12 | sonraki ▶ (+ "Bu hafta", haftanın toplamı) */ +/** ◀ önceki | 2. hafta / 4 · 09.12 – 15.12 | sonraki ▶ (+ "Bu hafta", haftanın toplamı) */ export function WeekPicker({ weeks, index, diff --git a/frontend/src/lib/budget-preview.test.ts b/frontend/src/lib/budget-preview.test.ts index cc24753..6fbc678 100644 --- a/frontend/src/lib/budget-preview.test.ts +++ b/frontend/src/lib/budget-preview.test.ts @@ -28,14 +28,15 @@ describe('splitProportional (backend ile aynı)', () => { }) describe('previewWeeks', () => { - const dec = [6, 7, 7, 7, 4] + // Aralık: 1–8, 9–15, 16–22, 23–31 (backend tests/budget.rs ile aynı) + const dec = [8, 7, 7, 9] it('all automatic', () => { const r = previewWeeks( 100_000, dec.map((days) => ({ days, manual: null })), ) - expect(r.weeks.map((w) => w.amount)).toEqual([19_355, 22_581, 22_581, 22_580, 12_903]) + expect(r.weeks.map((w) => w.amount)).toEqual([25_806, 22_581, 22_581, 29_032]) expect(r.weeklyOver).toBe(false) }) @@ -45,9 +46,9 @@ describe('previewWeeks', () => { dec.map((days, i) => ({ days, manual: i === 1 ? 40_000 : null })), ) expect(r.weeks[1]).toEqual({ amount: 40_000, isManual: true }) - // kalan 60.000 → 6,7,7,4 gün + // kalan 60.000 → 8,7,9 gün expect(r.weeks.filter((w) => !w.isManual).map((w) => w.amount)).toEqual([ - 15_000, 17_500, 17_500, 10_000, + 20_000, 17_500, 22_500, ]) expect(r.weeks.reduce((a, w) => a + w.amount, 0)).toBe(100_000) }) diff --git a/frontend/src/lib/ledger.test.ts b/frontend/src/lib/ledger.test.ts index b05eecb..14e2201 100644 --- a/frontend/src/lib/ledger.test.ts +++ b/frontend/src/lib/ledger.test.ts @@ -1,38 +1,30 @@ import { describe, expect, it } from 'vitest' import type { PeriodWeek } from '@/types/PeriodWeek' import type { Entry } from '@/types/Entry' -import { defaultEntryDate, groupByDay, parseWeekParam, weekIndexFor } from './ledger' +import { defaultEntryDate, groupByDay, weekIndexFor } from './ledger' -// Aralık 2026: 1–6 · 7–13 · 14–20 · 21–27 · 28–31 (backend testleriyle aynı) +// Aralık 2026: 1–8 · 9–15 · 16–22 · 23–31 (backend `calendar::weeks` ile aynı) const weeks: PeriodWeek[] = [ - ['2026-12-01', '2026-12-06'], - ['2026-12-07', '2026-12-13'], - ['2026-12-14', '2026-12-20'], - ['2026-12-21', '2026-12-27'], - ['2026-12-28', '2026-12-31'], + ['2026-12-01', '2026-12-08'], + ['2026-12-09', '2026-12-15'], + ['2026-12-16', '2026-12-22'], + ['2026-12-23', '2026-12-31'], ].map(([start_date, last_date], i) => ({ index: i + 1, start_date, last_date, days: 0 })) describe('ledger', () => { it('finds the week of a date, clamping outside the period', () => { - expect(weekIndexFor(weeks, '2026-12-06')).toBe(1) - expect(weekIndexFor(weeks, '2026-12-07')).toBe(2) - expect(weekIndexFor(weeks, '2026-12-31')).toBe(5) + expect(weekIndexFor(weeks, '2026-12-08')).toBe(1) + expect(weekIndexFor(weeks, '2026-12-09')).toBe(2) + expect(weekIndexFor(weeks, '2026-12-23')).toBe(4) + expect(weekIndexFor(weeks, '2026-12-31')).toBe(4) expect(weekIndexFor(weeks, '2026-11-12')).toBe(1) - expect(weekIndexFor(weeks, '2027-01-03')).toBe(5) + expect(weekIndexFor(weeks, '2027-01-03')).toBe(4) }) it('defaults the entry date to today, or the nearest day of the week', () => { - expect(defaultEntryDate(weeks[1], '2026-12-09')).toBe('2026-12-09') - expect(defaultEntryDate(weeks[1], '2026-12-20')).toBe('2026-12-13') // geçmiş hafta → son gün - expect(defaultEntryDate(weeks[1], '2026-12-02')).toBe('2026-12-07') // gelecek hafta → ilk gün - }) - - it('accepts only valid week numbers from the address', () => { - expect(parseWeekParam('3', 5)).toBe(3) - expect(parseWeekParam('0', 5)).toBeNull() - expect(parseWeekParam('6', 5)).toBeNull() - expect(parseWeekParam('2a', 5)).toBeNull() - expect(parseWeekParam(null, 5)).toBeNull() + expect(defaultEntryDate(weeks[1], '2026-12-13')).toBe('2026-12-13') + expect(defaultEntryDate(weeks[1], '2026-12-20')).toBe('2026-12-15') // geçmiş hafta → son gün + expect(defaultEntryDate(weeks[1], '2026-12-02')).toBe('2026-12-09') // gelecek hafta → ilk gün }) it('groups entries by day, newest day first', () => { diff --git a/frontend/src/lib/ledger.ts b/frontend/src/lib/ledger.ts index 87637fa..449c905 100644 --- a/frontend/src/lib/ledger.ts +++ b/frontend/src/lib/ledger.ts @@ -21,13 +21,6 @@ export function defaultEntryDate(week: PeriodWeek, today: string): string { return today } -/** Adresteki `?hafta=` değeri → geçerli hafta numarası ya da `null` */ -export function parseWeekParam(value: string | null, count: number): number | null { - if (!value || !/^\d+$/.test(value)) return null - const n = Number(value) - return n >= 1 && n <= count ? n : null -} - export type EntryDay = { date: string; total: number; entries: Entry[] } /** Kayıtları gün gün toplar; en yeni gün başta, gün içinde sunucu sırası korunur */ diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 65268a1..5757cd4 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -126,7 +126,7 @@ export const t = { last: 'Son gün', name: 'Dönem adı', nameHint: 'Boş bırakırsan tarihlere göre otomatik verilir (ör. "Aralık 2026").', - days: (n: number) => `${n} gün · haftalar Pazartesi başlar`, + days: (n: number) => `${n} gün · haftalar dönemin 1., 9., 16. ve 23. gününde başlar`, created: 'Dönem açıldı', startRequired: 'Başlangıç tarihini seçin', lastRequired: 'Son günü seçin',