fix(defter): haftalar ayın günlerine göre (1–8, 9–15, 16–22, 23–son), defter bugünün haftasıyla açılır

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 20:31:15 +03:00
parent 233907e3d9
commit 6e1000afd2
15 changed files with 144 additions and 130 deletions
+2 -2
View File
@@ -43,7 +43,7 @@ scripts/ seed, reset, backup
- Her sorgu `household_id` ile filtrelenir (ortak middleware). - Her sorgu `household_id` ile filtrelenir (ortak middleware).
- Hesaplanan değerler saklanmaz; aggregation ile hesaplanır. - Hesaplanan değerler saklanmaz; aggregation ile hesaplanır.
- Format: `1.250,00 ₺`, tarih `GG.AA.YYYY`; tümü `lib/format.ts` üzerinden. - 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. - 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` - Commit mesajları Türkçe: `feat(defter): haftalık giriş listesi eklendi`
@@ -73,7 +73,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--force]
- Geliştirmede tasarım önizlemesi: `/dev/tasarim`. - 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`). - 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ı). - 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`. - 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` - 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. - 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.
+1 -1
View File
@@ -162,7 +162,7 @@ kakeibo/
- Dönem **başlangıç ve bitiş tarihiyle** açılır. Örnek: `01.12.2026` – `01.01.2027`. - 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. - 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ı): - **Dönem kapatma** (sihirbaz adımları):
1. Özet ve planlanan/gerçekleşen karşılaştırması gösterilir. 1. Özet ve planlanan/gerçekleşen karşılaştırması gösterilir.
2. Kalan bakiye ve borç hesaplanır. 2. Kalan bakiye ve borç hesaplanır.
+76 -29
View File
@@ -52,20 +52,35 @@ pub struct Week {
pub days: u32, pub days: u32,
} }
/// Dönemi Pazartesi başlangıçlı haftalara böler. /// Dönemi **ayın günlerine göre** haftalara böler (kullanıcı kararı, 2026-11-13):
/// Baştaki ve sondaki kısmi haftalar ayrı hafta sayılır (KAKEIBO_PROMPT §6). /// 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<Week> { pub fn weeks(start: Date, end_exclusive: Date) -> Vec<Week> {
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; let mut cursor = start;
while cursor < end_exclusive { for i in 0..count {
// Bir sonraki Pazartesi (ya da dönem sonu, hangisi önce gelirse) let len = if i == count - 1 {
let to_monday = 7 - i64::from(cursor.weekday().number_days_from_monday()); days_between(cursor, end_exclusive)
let next = (cursor + Duration::days(to_monday)).min(end_exclusive); } else if i == 0 {
8
} else {
7
};
let next = cursor + Duration::days(len);
out.push(Week { out.push(Week {
index: out.len() as u32 + 1, index: i as u32 + 1,
start: cursor, start: cursor,
last: next - Duration::days(1), last: next - Duration::days(1),
days: days_between(cursor, next) as u32, days: len as u32,
}); });
cursor = next; cursor = next;
} }
@@ -123,37 +138,69 @@ mod tests {
assert!(parse_date("2028-02-29").is_some(), "artık yıl"); 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] #[test]
fn december_2026_weeks() { fn month_weeks_follow_day_of_month() {
// 1 Aralık 2026 Salı → ilk hafta 6 gün (Sal–Paz), son hafta 4 gün (Pzt–Per) // Kasım 2026 (1'i Pazar): 1–8, 9–15, 16–22, 23–30 → 4 hafta
let w = weeks(d("2026-12-01"), d("2027-01-01")); let w = weeks(d("2026-11-01"), d("2026-12-01"));
let summary: Vec<(String, String, u32)> = w
.iter()
.map(|w| (format_date(w.start), format_date(w.last), w.days))
.collect();
assert_eq!( assert_eq!(
summary, summary(&w),
vec![ vec![
("2026-12-01".into(), "2026-12-06".into(), 6), (1, "2026-11-01".into(), "2026-11-08".into(), 8),
("2026-12-07".into(), "2026-12-13".into(), 7), (2, "2026-11-09".into(), "2026-11-15".into(), 7),
("2026-12-14".into(), "2026-12-20".into(), 7), (3, "2026-11-16".into(), "2026-11-22".into(), 7),
("2026-12-21".into(), "2026-12-27".into(), 7), (4, "2026-11-23".into(), "2026-11-30".into(), 8),
("2026-12-28".into(), "2026-12-31".into(), 4),
] ]
); );
assert_eq!(w.iter().map(|w| w.days).sum::<u32>(), 31); // 31 gün → son hafta 23–31 (9 gün); Şubat → 23–28 (6 gün); artık yıl → 7 gün
assert_eq!(w.last().unwrap().index, 5); 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::<i64>(),
days_between(d(start), d(end))
);
}
} }
#[test] #[test]
fn weeks_edge_cases() { fn weeks_edge_cases() {
// Pazartesi başlayan, tam 2 hafta // Ay ortasında başlayan dönem: günler dönem başından sayılır
let w = weeks(d("2026-11-30"), d("2026-12-14")); let w = weeks(d("2026-12-15"), d("2027-01-15"));
assert_eq!(w.len(), 2); assert_eq!(
assert!(w.iter().all(|w| w.days == 7)); summary(&w)
// Tek gün (Pazar) .iter()
.map(|x| (x.1.as_str(), x.3))
.collect::<Vec<_>>(),
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")); let w = weeks(d("2026-12-06"), d("2026-12-07"));
assert_eq!((w.len(), w[0].days), (1, 1)); 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 // Boş aralık
assert!(weeks(d("2026-12-07"), d("2026-12-07")).is_empty()); assert!(weeks(d("2026-12-07"), d("2026-12-07")).is_empty());
} }
+13 -19
View File
@@ -139,7 +139,7 @@ async fn monthly_budget_is_split_into_weeks_by_days() {
// Başta hepsi 0 // Başta hepsi 0
let (_, b) = app.get(&url, &s).await; let (_, b) = app.get(&url, &s).await;
assert_eq!(b.as_array().unwrap().len(), 4); 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 let (st, b) = app
.put( .put(
@@ -151,12 +151,9 @@ async fn monthly_budget_is_split_into_weeks_by_days() {
assert_eq!(st, StatusCode::OK, "{b}"); assert_eq!(st, StatusCode::OK, "{b}");
let needs = group(&b, "needs"); let needs = group(&b, "needs");
assert_eq!(needs["monthly"], 100_000); assert_eq!(needs["monthly"], 100_000);
// Aralık: 6,7,7,7,4 gün → toplam tam 100.000 // Aralık: 1–8, 9–15, 16–22, 23–31 → 8,7,7,9 gün; toplam tam 100.000
assert_eq!( assert_eq!(week_amounts(needs), vec![25_806, 22_581, 22_581, 29_032]);
week_amounts(needs), assert_eq!(week_amounts(group(&b, "culture")), vec![8, 7, 7, 9]);
vec![19_355, 22_581, 22_581, 22_580, 12_903]
);
assert_eq!(week_amounts(group(&b, "culture")), vec![6, 7, 7, 7, 4]);
for g in b.as_array().unwrap() { for g in b.as_array().unwrap() {
assert_eq!( assert_eq!(
week_amounts(g).iter().sum::<i64>(), week_amounts(g).iter().sum::<i64>(),
@@ -207,20 +204,17 @@ async fn manual_week_override_redistributes_the_rest() {
.await; .await;
let weeks_url = format!("/periods/{pid}/budget/weeks"); 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 let (st, b) = app
.put( .put(
&weeks_url, &weeks_url,
&s, &s,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 50_000 }), json!({ "group": "needs", "week_start": "2026-12-09", "amount": 50_000 }),
) )
.await; .await;
assert_eq!(st, StatusCode::OK, "{b}"); assert_eq!(st, StatusCode::OK, "{b}");
let needs = group(&b, "needs"); let needs = group(&b, "needs");
assert_eq!( assert_eq!(week_amounts(needs), vec![16_667, 50_000, 14_583, 18_750]);
week_amounts(needs),
vec![12_500, 50_000, 14_584, 14_583, 8_333]
);
assert_eq!(week_amounts(needs).iter().sum::<i64>(), 100_000); assert_eq!(week_amounts(needs).iter().sum::<i64>(), 100_000);
let manual: Vec<bool> = needs["weeks"] let manual: Vec<bool> = needs["weeks"]
.as_array() .as_array()
@@ -228,7 +222,7 @@ async fn manual_week_override_redistributes_the_rest() {
.iter() .iter()
.map(|w| w["is_manual"].as_bool().unwrap()) .map(|w| w["is_manual"].as_bool().unwrap())
.collect(); .collect();
assert_eq!(manual, vec![false, true, false, false, false]); assert_eq!(manual, vec![false, true, false, false]);
assert_eq!(needs["weekly_over"], false); assert_eq!(needs["weekly_over"], false);
// Aylık tutar değişince elle girilen hafta korunur // Aylık tutar değişince elle girilen hafta korunur
@@ -247,25 +241,25 @@ async fn manual_week_override_redistributes_the_rest() {
.put( .put(
&weeks_url, &weeks_url,
&s, &s,
json!({ "group": "needs", "week_start": "2026-12-14", "amount": 30_000 }), json!({ "group": "needs", "week_start": "2026-12-16", "amount": 30_000 }),
) )
.await; .await;
let needs = group(&b, "needs"); let needs = group(&b, "needs");
assert_eq!(needs["weekly_over"], true); 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 // null → otomatiğe dön
app.put( app.put(
&weeks_url, &weeks_url,
&s, &s,
json!({ "group": "needs", "week_start": "2026-12-14", "amount": null }), json!({ "group": "needs", "week_start": "2026-12-16", "amount": null }),
) )
.await; .await;
let (_, b) = app let (_, b) = app
.put( .put(
&weeks_url, &weeks_url,
&s, &s,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": null }), json!({ "group": "needs", "week_start": "2026-12-09", "amount": null }),
) )
.await; .await;
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000); assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
@@ -372,7 +366,7 @@ async fn budget_closed_period_and_isolation() {
app.put( app.put(
&format!("/periods/{pid}/budget/weeks"), &format!("/periods/{pid}/budget/weeks"),
&b, &b,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 1 }), json!({ "group": "needs", "week_start": "2026-12-09", "amount": 1 }),
) )
.await .await
.0, .0,
+9 -12
View File
@@ -495,11 +495,11 @@ async fn actual_spending_in_summary_and_budget() {
) )
.await; .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 [ for (date, amount, cat) in [
("2026-12-01", 1_000, &needs), ("2026-12-01", 1_000, &needs),
("2026-12-06", 2_000, &needs), // 1. haftanın son günü ("2026-12-08", 2_000, &needs), // 1. haftanın son günü
("2026-12-07", 500, &needs), // 2. haftanın ilk günü ("2026-12-09", 500, &needs), // 2. haftanın ilk günü
("2026-12-31", 300, &wants), // dönemin son günü ("2026-12-31", 300, &wants), // dönemin son günü
] { ] {
let (st, b) = add( let (st, b) = add(
@@ -516,7 +516,7 @@ async fn actual_spending_in_summary_and_budget() {
&app, &app,
&s, &s,
&pid, &pid,
json!({ "date": "2026-12-08", "amount": 400, "category_id": needs }), json!({ "date": "2026-12-10", "amount": 400, "category_id": needs }),
) )
.await; .await;
app.patch( app.patch(
@@ -530,7 +530,7 @@ async fn actual_spending_in_summary_and_budget() {
&app, &app,
&s, &s,
&pid, &pid,
json!({ "date": "2026-12-15", "amount": 700, "category_id": culture }), json!({ "date": "2026-12-16", "amount": 700, "category_id": culture }),
) )
.await; .await;
let gone_id = gone["id"].as_str().unwrap(); 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; let (st, b) = app.get(&format!("/periods/{pid}/budget"), &s).await;
assert_eq!(st, StatusCode::OK); assert_eq!(st, StatusCode::OK);
assert_eq!( assert_eq!(spent_by_week(&b, "needs"), (3_500, vec![3_000, 500, 0, 0]));
spent_by_week(&b, "needs"), assert_eq!(spent_by_week(&b, "wants"), (700, vec![0, 400, 0, 300]));
(3_500, vec![3_000, 500, 0, 0, 0]) assert_eq!(spent_by_week(&b, "culture"), (0, vec![0; 4]));
);
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]));
// Geri al → yeniden sayılır // Geri al → yeniden sayılır
app.post(&format!("/entries/{gone_id}/restore"), &s, json!({})) app.post(&format!("/entries/{gone_id}/restore"), &s, json!({}))
.await; .await;
let (_, b) = app.get(&format!("/periods/{pid}/budget"), &s).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 // Harcanabilir aşılırsa kalan negatif
add( add(
+1 -5
View File
@@ -40,11 +40,7 @@ async fn open_period_returns_weeks_and_default_name() {
.iter() .iter()
.map(|w| w["days"].as_i64().unwrap()) .map(|w| w["days"].as_i64().unwrap())
.collect(); .collect();
assert_eq!( assert_eq!(days, vec![8, 7, 7, 9], "1–8, 9–15, 16–22, 23–31");
days,
vec![6, 7, 7, 7, 4],
"Pazartesi başlangıçlı, kısmi haftalar ayrı"
);
let (_, cur) = app.get("/periods/current", &s).await; let (_, cur) = app.get("/periods/current", &s).await;
assert_eq!(cur["id"], p["id"]); assert_eq!(cur["id"], p["id"]);
+7 -7
View File
@@ -95,11 +95,11 @@ async fn four_questions_and_breakdowns() {
let culture = category(&app, &s, "culture", 0).await; let culture = category(&app, &s, "culture", 0).await;
// Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31 // 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-01", 100_000, &market).await;
spend(&app, &s, &pid, "2026-12-08", 50_000, &market).await; spend(&app, &s, &pid, "2026-12-09", 50_000, &market).await;
spend(&app, &s, &pid, "2026-12-08", 300_000, &rent).await; spend(&app, &s, &pid, "2026-12-09", 300_000, &rent).await;
spend(&app, &s, &pid, "2026-12-15", 40_000, &wants).await; spend(&app, &s, &pid, "2026-12-16", 40_000, &wants).await;
spend(&app, &s, &pid, "2026-12-31", 10_000, &culture).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) app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
.await; .await;
@@ -145,13 +145,13 @@ async fn four_questions_and_breakdowns() {
// Haftalar: 4 grubun toplamı; planlananların toplamı aylık bütçelerin toplamı // Haftalar: 4 grubun toplamı; planlananların toplamı aylık bütçelerin toplamı
assert_eq!( assert_eq!(
column(&r["weeks"], "spent"), 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!( assert_eq!(
column(&r["weeks"], "planned").iter().sum::<i64>(), column(&r["weeks"], "planned").iter().sum::<i64>(),
1_800_000 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; 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 // Hiç bütçe yoksa: gruplar 0 planlı, haftalar yine dönem takviminden
assert_eq!(column(&r["groups"], "planned"), vec![0; 4]); 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; app.cleanup().await;
} }
+1 -1
View File
@@ -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. - **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). - 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). - **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. - **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 ### 3.5 `categories` — Alt kategoriler
+3 -1
View File
@@ -1,10 +1,11 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ 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-<hane>-<tarih>.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 ✅. **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
- **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). - **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. - **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. - **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 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 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. - **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).
+10 -18
View File
@@ -1,5 +1,5 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useLocation, useSearchParams } from 'react-router' import { useLocation } from 'react-router'
import { Info } from 'lucide-react' import { Info } from 'lucide-react'
import { PageError, PageHeader, PageLoading } from '@/components/page' import { PageError, PageHeader, PageLoading } from '@/components/page'
import { PeriodPicker } from '@/features/periods/period-picker' 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 { useCategories, usePaymentMethods } from '@/lib/catalog'
import { useEntries } from '@/lib/entries' import { useEntries } from '@/lib/entries'
import { toIsoDate } from '@/lib/format' import { toIsoDate } from '@/lib/format'
import { parseWeekParam, weekIndexFor } from '@/lib/ledger' import { weekIndexFor } from '@/lib/ledger'
import { isWritable } from '@/lib/period-select' import { isWritable } from '@/lib/period-select'
import { useBudget } from '@/lib/periods' import { useBudget } from '@/lib/periods'
import { t } from '@/lib/strings' 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). * 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 }) { function LedgerView({ period }: { period: Period }) {
const [params, setParams] = useSearchParams()
const [today] = useState(() => toIsoDate(new Date())) const [today] = useState(() => toIsoDate(new Date()))
const [editing, setEditing] = useState<Entry | null>(null) const [editing, setEditing] = useState<Entry | null>(null)
// Panel'deki "Harcama Ekle" → tutar alanına odaklan // Panel'deki "Harcama Ekle" → tutar alanına odaklan
@@ -45,9 +45,9 @@ function LedgerView({ period }: { period: Period }) {
const formRef = useRef<HTMLDivElement>(null) const formRef = useRef<HTMLDivElement>(null)
const inPeriod = today >= period.start_date && today <= period.last_date const inPeriod = today >= period.start_date && today <= period.last_date
const todayIndex = inPeriod ? weekIndexFor(period.weeks, today) : null const todayIndex = inPeriod ? weekIndexFor(period.weeks, today) : null
const index = const [index, setIndex] = useState(() => weekIndexFor(period.weeks, today))
parseWeekParam(params.get('hafta'), period.weeks.length) ?? weekIndexFor(period.weeks, today) // Dönem tarihleri değişip hafta sayısı azalırsa son haftaya düş
const week = period.weeks[index - 1] const week = period.weeks[Math.min(index, period.weeks.length) - 1]
const readOnly = !isWritable(period) const readOnly = !isWritable(period)
const budget = useBudget(period.id) const budget = useBudget(period.id)
@@ -57,15 +57,7 @@ function LedgerView({ period }: { period: Period }) {
const goTo = (n: number) => { const goTo = (n: number) => {
setEditing(null) setEditing(null)
setParams( setIndex(n)
(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 },
)
} }
function edit(e: Entry) { function edit(e: Entry) {
@@ -81,7 +73,7 @@ function LedgerView({ period }: { period: Period }) {
<div className="mx-auto max-w-3xl lg:max-w-none"> <div className="mx-auto max-w-3xl lg:max-w-none">
<WeekPicker <WeekPicker
weeks={period.weeks} weeks={period.weeks}
index={index} index={week.index}
todayIndex={todayIndex} todayIndex={todayIndex}
budget={budget.data} budget={budget.data}
onChange={goTo} onChange={goTo}
@@ -123,7 +115,7 @@ function LedgerView({ period }: { period: Period }) {
{budget.isError ? ( {budget.isError ? (
<PageError message={budget.error.message} onRetry={budget.refetch} /> <PageError message={budget.error.message} onRetry={budget.refetch} />
) : budget.data ? ( ) : budget.data ? (
<WeekSummary budget={budget.data} index={index} /> <WeekSummary budget={budget.data} index={week.index} />
) : ( ) : (
<PageLoading /> <PageLoading />
)} )}
+1 -1
View File
@@ -7,7 +7,7 @@ import type { PeriodWeek } from '@/types/PeriodWeek'
const s = t.ledger 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({ export function WeekPicker({
weeks, weeks,
index, index,
+5 -4
View File
@@ -28,14 +28,15 @@ describe('splitProportional (backend ile aynı)', () => {
}) })
describe('previewWeeks', () => { 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', () => { it('all automatic', () => {
const r = previewWeeks( const r = previewWeeks(
100_000, 100_000,
dec.map((days) => ({ days, manual: null })), 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) expect(r.weeklyOver).toBe(false)
}) })
@@ -45,9 +46,9 @@ describe('previewWeeks', () => {
dec.map((days, i) => ({ days, manual: i === 1 ? 40_000 : null })), dec.map((days, i) => ({ days, manual: i === 1 ? 40_000 : null })),
) )
expect(r.weeks[1]).toEqual({ amount: 40_000, isManual: true }) 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([ 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) expect(r.weeks.reduce((a, w) => a + w.amount, 0)).toBe(100_000)
}) })
+14 -22
View File
@@ -1,38 +1,30 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { PeriodWeek } from '@/types/PeriodWeek' import type { PeriodWeek } from '@/types/PeriodWeek'
import type { Entry } from '@/types/Entry' 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[] = [ const weeks: PeriodWeek[] = [
['2026-12-01', '2026-12-06'], ['2026-12-01', '2026-12-08'],
['2026-12-07', '2026-12-13'], ['2026-12-09', '2026-12-15'],
['2026-12-14', '2026-12-20'], ['2026-12-16', '2026-12-22'],
['2026-12-21', '2026-12-27'], ['2026-12-23', '2026-12-31'],
['2026-12-28', '2026-12-31'],
].map(([start_date, last_date], i) => ({ index: i + 1, start_date, last_date, days: 0 })) ].map(([start_date, last_date], i) => ({ index: i + 1, start_date, last_date, days: 0 }))
describe('ledger', () => { describe('ledger', () => {
it('finds the week of a date, clamping outside the period', () => { it('finds the week of a date, clamping outside the period', () => {
expect(weekIndexFor(weeks, '2026-12-06')).toBe(1) expect(weekIndexFor(weeks, '2026-12-08')).toBe(1)
expect(weekIndexFor(weeks, '2026-12-07')).toBe(2) expect(weekIndexFor(weeks, '2026-12-09')).toBe(2)
expect(weekIndexFor(weeks, '2026-12-31')).toBe(5) 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, '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', () => { 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-13')).toBe('2026-12-13')
expect(defaultEntryDate(weeks[1], '2026-12-20')).toBe('2026-12-13') // geçmiş hafta → son gün 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-07') // gelecek hafta → ilk gün expect(defaultEntryDate(weeks[1], '2026-12-02')).toBe('2026-12-09') // 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()
}) })
it('groups entries by day, newest day first', () => { it('groups entries by day, newest day first', () => {
-7
View File
@@ -21,13 +21,6 @@ export function defaultEntryDate(week: PeriodWeek, today: string): string {
return today 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[] } 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 */ /** Kayıtları gün gün toplar; en yeni gün başta, gün içinde sunucu sırası korunur */
+1 -1
View File
@@ -126,7 +126,7 @@ export const t = {
last: 'Son gün', last: 'Son gün',
name: 'Dönem adı', name: 'Dönem adı',
nameHint: 'Boş bırakırsan tarihlere göre otomatik verilir (ör. "Aralık 2026").', 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ı', created: 'Dönem açıldı',
startRequired: 'Başlangıç tarihini seçin', startRequired: 'Başlangıç tarihini seçin',
lastRequired: 'Son günü seçin', lastRequired: 'Son günü seçin',