diff --git a/backend/api.http b/backend/api.http index 251172f..0a29282 100644 --- a/backend/api.http +++ b/backend/api.http @@ -300,3 +300,16 @@ DELETE {{baseUrl}}/entries/BURAYA-KAYIT-ID ### Silmeyi geri al ("↩️ Geri al") POST {{baseUrl}}/entries/BURAYA-KAYIT-ID/restore + +# ─────────────────────────── Ay sonu değerlendirmesi ─────────────────────────── + +### Değerlendirme: 4 soru (available, savings_goal, spent_total, realized_savings) + grup/kategori/hafta +GET {{baseUrl}}/periods/BURAYA-DONEM-ID/review + +### "Nasıl daha iyi yapabilirim?" (Markdown; boş metin → kaldır; yalnızca açık dönem) +PATCH {{baseUrl}}/periods/BURAYA-DONEM-ID +Content-Type: application/json + +{ + "reflection_md": "- Dışarıda yemeği haftada 1'e indir\n- Market listesiyle alışveriş" +} diff --git a/backend/src/models/period.rs b/backend/src/models/period.rs index 04453c3..b2994bf 100644 --- a/backend/src/models/period.rs +++ b/backend/src/models/period.rs @@ -35,5 +35,8 @@ pub struct Period { pub status: PeriodStatus, /// Kuruş pub savings_goal: i64, + /// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası) + #[serde(default)] + pub reflection_md: Option, pub version: i32, } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index a8871e4..b66256c 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -9,6 +9,7 @@ mod household; mod note; mod period; mod plan; +mod review; mod spa; use axum::{ @@ -78,6 +79,7 @@ pub fn router(state: AppState) -> Router { .route("/plan-items/{id}", patch(plan::update).delete(plan::delete)) .route("/periods/{id}/notes", get(note::list).post(note::create)) .route("/periods/{id}/summary", get(budget::summary)) + .route("/periods/{id}/review", get(review::get)) .route( "/periods/{id}/budget", get(budget::get).put(budget::set_monthly), diff --git a/backend/src/routes/period.rs b/backend/src/routes/period.rs index 397f696..11cd605 100644 --- a/backend/src/routes/period.rs +++ b/backend/src/routes/period.rs @@ -54,6 +54,8 @@ pub struct UpdateRequest { end_date: Option, #[ts(type = "number", optional)] savings_goal: Option, + /// "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır + reflection_md: Option, version: Option, } @@ -73,6 +75,8 @@ pub struct PeriodDto { /// Kuruş #[ts(type = "number")] savings_goal: i64, + /// "Nasıl daha iyi yapabilirim?" (Markdown) + reflection_md: Option, version: i32, weeks: Vec, } @@ -102,6 +106,7 @@ impl From for PeriodDto { end_date: p.end_date, status: p.status, savings_goal: p.savings_goal, + reflection_md: p.reflection_md, version: p.version, weeks: weeks(start, end) .into_iter() @@ -178,6 +183,7 @@ pub async fn update( start_date: req.start_date, end_date: req.end_date, savings_goal: req.savings_goal, + reflection_md: req.reflection_md, version: req.version, }, ) diff --git a/backend/src/routes/review.rs b/backend/src/routes/review.rs new file mode 100644 index 0000000..323ab65 --- /dev/null +++ b/backend/src/routes/review.rs @@ -0,0 +1,152 @@ +//! Ay sonu değerlendirmesi: +//! +//! | Yöntem | Yol | İş | +//! |---|---|---| +//! | GET | `/api/periods/{id}/review` | 4 soru, grup/kategori/hafta bazında planlanan–gerçekleşen | +//! +//! "Nasıl daha iyi yapabilirim?" metni: `PATCH /api/periods/{id}` → `reflection_md`. + +use axum::{ + Json, + extract::{Path, State}, +}; +use mongodb::bson::Uuid; +use serde::Serialize; +use ts_rs::TS; + +use crate::{ + auth::AuthUser, + error::{AppError, AppResult}, + models::group::Group, + services::review, + state::AppState, +}; + +#[derive(Serialize, TS)] +#[ts(export, rename = "PeriodReview")] +pub struct ReviewDto { + #[ts(type = "number")] + income_total: i64, + #[ts(type = "number")] + fixed_expense_total: i64, + #[ts(type = "number")] + carryover_debt: i64, + #[ts(type = "number")] + carryover_surplus: i64, + /// 1. Ne kadar param vardı? (gelir − sabit gider − borç + artı) + #[ts(type = "number")] + available: i64, + /// 2. Ne kadar biriktirmek istedim? + #[ts(type = "number")] + savings_goal: i64, + /// 3. Ne kadar harcadım? + #[ts(type = "number")] + spent_total: i64, + /// 4. Ne kadar biriktirdim? (negatifse açık var) + #[ts(type = "number")] + realized_savings: i64, + /// Gerçekleşen − hedef birikim (negatifse hedefin altında) + #[ts(type = "number")] + savings_gap: i64, + /// 4 grup, sabit sırada + groups: Vec, + /// Harcama olan kategoriler, çoktan aza + categories: Vec, + /// Haftalar: 4 grubun toplamı + weeks: Vec, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "ReviewGroup")] +struct ReviewGroupDto { + group: Group, + /// Aylık bütçe + #[ts(type = "number")] + planned: i64, + #[ts(type = "number")] + spent: i64, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "ReviewCategory")] +struct ReviewCategoryDto { + category_id: String, + group: Group, + #[ts(type = "number")] + spent: i64, + /// Kayıt sayısı + #[ts(type = "number")] + count: i64, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "ReviewWeek")] +struct ReviewWeekDto { + index: u32, + start_date: String, + last_date: String, + #[ts(type = "number")] + planned: i64, + #[ts(type = "number")] + spent: i64, +} + +pub async fn get( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult> { + let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?; + let r = review::review(&s.db, auth.household_id, id).await?; + + // Haftalar: her grubun aynı sıradaki haftası toplanır (hepsi aynı takvimden gelir) + let weeks = r + .groups + .first() + .map(|g| { + g.weeks + .iter() + .enumerate() + .map(|(i, w)| ReviewWeekDto { + index: w.index, + start_date: w.start_date.clone(), + last_date: w.last_date.clone(), + planned: r.groups.iter().map(|g| g.weeks[i].amount).sum(), + spent: r.groups.iter().map(|g| g.weeks[i].spent).sum(), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(Json(ReviewDto { + income_total: r.summary.income_total, + fixed_expense_total: r.summary.fixed_expense_total, + carryover_debt: r.summary.carryover_debt, + carryover_surplus: r.summary.carryover_surplus, + available: r.available, + savings_goal: r.summary.savings_goal, + spent_total: r.summary.spent_total, + realized_savings: r.realized_savings, + savings_gap: r.realized_savings - r.summary.savings_goal, + groups: r + .groups + .iter() + .map(|g| ReviewGroupDto { + group: g.group, + planned: g.monthly, + spent: g.spent, + }) + .collect(), + categories: r + .categories + .into_iter() + .map(|c| ReviewCategoryDto { + category_id: c.category_id.to_string(), + group: c.group, + spent: c.spent, + count: c.count, + }) + .collect(), + weeks, + })) +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index c12d07b..dae8eff 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -10,4 +10,5 @@ pub mod money; pub mod note; pub mod period; pub mod plan; +pub mod review; pub mod seed; diff --git a/backend/src/services/period.rs b/backend/src/services/period.rs index aaf669f..6f00813 100644 --- a/backend/src/services/period.rs +++ b/backend/src/services/period.rs @@ -41,9 +41,14 @@ pub struct PeriodChanges { pub start_date: Option, pub end_date: Option, pub savings_goal: Option, + /// `Some("")` → metni kaldır + pub reflection_md: Option, pub version: Option, } +/// "Nasıl daha iyi yapabilirim?" üst sınırı (karakter) — notlarla aynı +pub const MAX_REFLECTION: usize = 20_000; + fn coll(db: &Database) -> mongodb::Collection { db.collection(col::PERIODS) } @@ -177,6 +182,9 @@ pub async fn update( if let Some(goal) = ch.savings_goal { set.insert("savings_goal", validate_amount(goal)?); } + if let Some(text) = ch.reflection_md { + set.insert("reflection_md", clean_reflection(&text)?); + } let mut f = scope(household_id); f.insert("_id", id); @@ -190,6 +198,19 @@ pub async fn update( // ---------- doğrulamalar ---------- +fn clean_reflection(text: &str) -> AppResult { + let t = text.trim(); + if t.is_empty() { + return Ok(Bson::Null); + } + if t.chars().count() > MAX_REFLECTION { + return Err(AppError::BadRequest( + "Metin en fazla 20.000 karakter olabilir".into(), + )); + } + Ok(Bson::String(t.to_string())) +} + fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> { let bad = |m: &str| AppError::BadRequest(m.into()); let start = parse_date(start).ok_or_else(|| bad("Başlangıç tarihi geçersiz"))?; diff --git a/backend/src/services/review.rs b/backend/src/services/review.rs new file mode 100644 index 0000000..dde78f7 --- /dev/null +++ b/backend/src/services/review.rs @@ -0,0 +1,105 @@ +//! Ay sonu değerlendirmesi (KAKEIBO_PROMPT §8.5, DATA_MODEL.md §5). +//! +//! Kakeibo'nun dört sorusu — hepsi sorgu anında hesaplanır, saklanmaz: +//! 1. Ne kadar param vardı? `available` = gelir − sabit gider − devreden borç + devreden artı +//! 2. Ne kadar biriktirmek istedim? `savings_goal` +//! 3. Ne kadar harcadım? `spent_total` +//! 4. Ne kadar biriktirdim? `realized_savings` = available − spent_total +//! +//! Ek olarak: grup bazlı planlanan/gerçekleşen, kategori bazlı harcama, haftalık dağılım. +//! Kapalı dönemde de okunabilir. + +use mongodb::{ + Database, + bson::{Document, Uuid, doc}, +}; +use serde::Deserialize; + +use crate::{ + db::col, + error::AppResult, + models::{common::scope, group::Group}, + services::budget::{self, GroupBudget, Summary}, +}; + +pub struct Review { + pub summary: Summary, + /// 1. soru + pub available: i64, + /// 4. soru (negatifse açık var → kapanışta borç olarak devreder) + pub realized_savings: i64, + /// Grup: aylık bütçe ve gerçekleşen (`Group::ALL` sırasıyla) + pub groups: Vec, + /// Harcama olan kategoriler, çoktan aza + pub categories: Vec, +} + +pub struct CategorySpent { + pub category_id: Uuid, + pub group: Group, + pub spent: i64, + pub count: i64, +} + +pub async fn review(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult { + // `summary` dönemin bu haneye ait olduğunu da denetler (yoksa 404) + let summary = budget::summary(db, household_id, period_id).await?; + let groups = budget::view(db, household_id, period_id).await?; + let categories = spent_by_category(db, household_id, period_id).await?; + let available = summary.income_total - summary.fixed_expense_total - summary.carryover_debt + + summary.carryover_surplus; + Ok(Review { + realized_savings: available - summary.spent_total, + available, + summary, + groups, + categories, + }) +} + +async fn spent_by_category( + db: &Database, + household_id: Uuid, + period_id: Uuid, +) -> AppResult> { + #[derive(Deserialize)] + struct Key { + category_id: Uuid, + group: Group, + } + #[derive(Deserialize)] + struct Row { + #[serde(rename = "_id")] + key: Key, + total: i64, + count: i64, + } + let mut m = scope(household_id); + m.insert("period_id", period_id); + let pipeline = vec![ + doc! { "$match": m }, + doc! { "$group": { + "_id": { "category_id": "$category_id", "group": "$group" }, + "total": { "$sum": "$amount" }, + "count": { "$sum": 1_i64 }, + } }, + // Eşit tutarda sıra her seferinde aynı olsun + doc! { "$sort": { "total": -1, "_id.category_id": 1 } }, + ]; + let mut cursor = db + .collection::(col::ENTRIES) + .aggregate(pipeline) + .with_type::() + .await?; + let mut out = Vec::new(); + while cursor.advance().await? { + let r = cursor.deserialize_current()?; + out.push(CategorySpent { + category_id: r.key.category_id, + group: r.key.group, + spent: r.total, + count: r.count, + }); + } + Ok(out) +} diff --git a/backend/tests/review.rs b/backend/tests/review.rs new file mode 100644 index 0000000..bc7dd80 --- /dev/null +++ b/backend/tests/review.rs @@ -0,0 +1,241 @@ +//! Ay sonu değerlendirmesi: dört soru, grup/kategori/hafta dökümü, "Nasıl daha iyi yapabilirim?". + +mod common; + +use axum::http::StatusCode; +use common::{Session, TestApp}; +use mongodb::bson::{Document, Uuid, doc}; +use serde_json::{Value, json}; + +async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String { + let (st, p) = app + .post( + "/periods", + s, + json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{p}"); + p["id"].as_str().unwrap().to_string() +} + +/// Gruptaki n'inci (0'dan) örnek kategori +async fn category(app: &TestApp, s: &Session, group: &str, n: usize) -> String { + let (_, cats) = app.get("/categories", s).await; + cats.as_array() + .unwrap() + .iter() + .filter(|c| c["group"] == group) + .nth(n) + .unwrap()["id"] + .as_str() + .unwrap() + .to_string() +} + +async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64, cat: &str) -> Value { + let (st, e) = app + .post( + &format!("/periods/{pid}/entries"), + s, + json!({ "date": date, "amount": amount, "category_id": cat }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{e}"); + e +} + +async fn plan(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) { + let (st, b) = app + .post( + &format!("/periods/{pid}/plan-items"), + s, + json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{b}"); +} + +fn column(list: &Value, key: &str) -> Vec { + list.as_array() + .unwrap() + .iter() + .map(|x| x[key].as_i64().unwrap()) + .collect() +} + +#[tokio::test] +async fn four_questions_and_breakdowns() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let pid = open_december(&app, &s, 500_000).await; + plan(&app, &s, &pid, "income", 4_800_000).await; + plan(&app, &s, &pid, "expense", 1_545_000).await; + app.db + .collection::("carryovers") + .insert_one(doc! { + "household_id": Uuid::parse_str(&s.household_id).unwrap(), + "to_period_id": Uuid::parse_str(&pid).unwrap(), + "kind": "debt", "amount": 100_000_i64, "deleted_at": null, + }) + .await + .unwrap(); + app.put( + &format!("/periods/{pid}/budget"), + &s, + json!({ "needs": 1_000_000, "wants": 500_000, "culture": 200_000, "unexpected": 100_000 }), + ) + .await; + + let (market, rent) = ( + category(&app, &s, "needs", 0).await, + category(&app, &s, "needs", 1).await, + ); + let wants = category(&app, &s, "wants", 0).await; + 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-31", 10_000, &culture).await; + let gone = spend(&app, &s, &pid, "2026-12-15", 99_999, &wants).await; + app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s) + .await; + + let (st, r) = app.get(&format!("/periods/{pid}/review"), &s).await; + assert_eq!(st, StatusCode::OK, "{r}"); + // 1: 4.800.000 − 1.545.000 − 100.000 = 3.155.000 · 3: 500.000 · 4: 3.155.000 − 500.000 + for (k, v) in [ + ("available", 3_155_000), + ("savings_goal", 500_000), + ("spent_total", 500_000), + ("realized_savings", 2_655_000), + ("savings_gap", 2_155_000), + ("carryover_debt", 100_000), + ] { + assert_eq!(r[k], v, "{k}: {r}"); + } + assert_eq!( + r["groups"], + json!([ + { "group": "needs", "planned": 1_000_000, "spent": 450_000 }, + { "group": "wants", "planned": 500_000, "spent": 40_000 }, + { "group": "culture", "planned": 200_000, "spent": 10_000 }, + { "group": "unexpected", "planned": 100_000, "spent": 0 }, + ]) + ); + // Kategoriler çoktan aza; silinen kayıt sayılmaz + let cats = r["categories"].as_array().unwrap(); + assert_eq!( + column(&r["categories"], "spent"), + vec![300_000, 150_000, 40_000, 10_000] + ); + assert_eq!( + ( + cats[0]["category_id"].as_str(), + cats[1]["category_id"].as_str() + ), + (Some(rent.as_str()), Some(market.as_str())) + ); + assert_eq!( + (cats[1]["count"].as_i64(), cats[1]["group"].as_str()), + (Some(2), Some("needs")) + ); + // 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] + ); + assert_eq!( + column(&r["weeks"], "planned").iter().sum::(), + 1_800_000 + ); + assert_eq!(r["weeks"][4]["last_date"], "2026-12-31"); + + app.cleanup().await; +} + +#[tokio::test] +async fn overspending_gives_negative_savings() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let pid = open_december(&app, &s, 100_000).await; + plan(&app, &s, &pid, "income", 200_000).await; + let needs = category(&app, &s, "needs", 0).await; + spend(&app, &s, &pid, "2026-12-10", 250_000, &needs).await; + + let (_, r) = app.get(&format!("/periods/{pid}/review"), &s).await; + assert_eq!( + ( + r["available"].as_i64(), + r["realized_savings"].as_i64(), + r["savings_gap"].as_i64() + ), + (Some(200_000), Some(-50_000), Some(-150_000)) + ); + // 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); + + app.cleanup().await; +} + +#[tokio::test] +async fn reflection_text_and_access_rules() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let other = app.register("Diger", None).await; + let pid = open_december(&app, &s, 0).await; + let url = format!("/periods/{pid}"); + + let (st, p) = app + .patch( + &url, + &s, + json!({ "reflection_md": " **Dışarıda yemeği** azalt. " }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{p}"); + assert_eq!(p["reflection_md"], "**Dışarıda yemeği** azalt."); + let (_, p) = app.get(&url, &s).await; + assert_eq!(p["reflection_md"], "**Dışarıda yemeği** azalt."); + + // Türkçe karakterler tek sayılır; sınırın üstü 400 + let (st, _) = app + .patch(&url, &s, json!({ "reflection_md": "ş".repeat(20_000) })) + .await; + assert_eq!(st, StatusCode::OK); + let (st, b) = app + .patch(&url, &s, json!({ "reflection_md": "a".repeat(20_001) })) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + assert_eq!( + b["error"]["message"], + "Metin en fazla 20.000 karakter olabilir" + ); + + // Boş metin → kaldırılır + let (_, p) = app.patch(&url, &s, json!({ "reflection_md": "" })).await; + assert_eq!(p["reflection_md"], Value::Null); + + // Başka hane göremez + let (st, _) = app.get(&format!("{url}/review"), &other).await; + assert_eq!(st, StatusCode::NOT_FOUND); + + // Kapalı dönem: değerlendirme okunur, metin değiştirilemez + app.db + .collection::("periods") + .update_one( + doc! { "_id": Uuid::parse_str(&pid).unwrap() }, + doc! { "$set": { "status": "closed" } }, + ) + .await + .unwrap(); + let (st, _) = app.get(&format!("{url}/review"), &s).await; + assert_eq!(st, StatusCode::OK); + let (st, _) = app.patch(&url, &s, json!({ "reflection_md": "geç" })).await; + assert_eq!(st, StatusCode::CONFLICT); + + app.cleanup().await; +} diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 716d7cb..c6d87cf 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,8 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 5 – Adım 5 (kullanıcıda):** `docs/DEPLOY.md` §6.1–6.3: Atlas'a betik + systemd timer kurulumu, ilk yedek, `restore.sh latest kakeibo_restore_test` ile karşılaştırma, bu bilgisayarda pull timer. Kullanıcı çıktıları (journal satırları, "✅ kakeibo ile aynı") bildirince Faz 5 ✅ → Faz 6 (ay sonu değerlendirmesi + dönem kapatma/devir). +**Faz 6 – Adım 2:** Backend dönem kapatma (`POST /periods/{id}/close`, tek transaction): `status=closed`, `closed_at/by`; gerçekleşen birikim < 0 → `carryovers{kind: debt}`, > 0 → kullanıcının `to_savings` kadarı birikime (Faz 8'e kadar dönemde saklanır), kalanı `surplus`; devrin `to_period_id`'si sonraki dönem açılınca bağlanır. Yeniden açma (sahip): devirler soft delete. **Kullanıcıya sorulacak:** başka açık dönem varken eski dönem nasıl yeniden açılır? +**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 - **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`). @@ -104,3 +105,6 @@ - **Adım 3:** Defter ekranı (`features/ledger/`): `WeekPicker` (◀ ▶, "Bu hafta", haftanın harcanan/bütçe toplamı), `EntryForm` (büyük `MoneyInput`, 4 renkli grup düğmesi, kategori/ödeme türü çipleri, tarih, açıklama; kayıttan sonra tutar+açıklama temizlenir, odak tutara döner), `schema.ts` (`entrySchema(start,last)`, `fieldForError`: sunucu mesajı → alan). Hafta adreste: `/defter?hafta=N` (bugünün haftasında parametre yok); başka haftaya tarihli kayıt → o haftaya geçer; hafta değişince tarih o haftaya çekilir. `lib/ledger.ts` (`weekIndexFor`, `defaultEntryDate`, `parseWeekParam`, son seçim localStorage `kakeibo.ledger.last`: grup + **her grubun son kategorisi** + ödeme türü; pasifleşen seçim düşer). `lib/catalog.ts` (`useCategories`, `usePaymentMethods`, 5 dk staleTime), `lib/entries.ts` (`useEntries`, `useCreateEntry`), `queryKeys.entries`. Frontend 79 test; e2e 31 kontrol (ayrı 8091 sunucu, DB silindi). Mobilde "Beklenmeyen" taşması düzeltildi. - **Adım 4:** Defter listesi ve özet: `entry-list.tsx` (gün gün `groupByDay`, gün adı `formatWeekday` = Intl tr-TR UTC; satır: kategori emojisi grup renginde, grup · ödeme · açıklama; düzenle/sil 44 px), sil → sonner `action` "↩️ Geri al" (8 sn) → `useRestoreEntry`. Düzenleme aynı `EntryForm` (`entry` + `key`, Güncelle/Vazgeç; `version` gönderilir, 409 Türkçe root hatası; `''` → ödeme/açıklama kaldır). Katalog **pasifler dahil** okunur (eski kayıtların adı; seçenekte yalnız aktifler + kaydın mevcut değeri). `week-summary.tsx` grup bazlı bütçe/harcanan/kalan + çubuk, aşımda "Aşıldı" kırmızı. Masaüstü iki sütun (form solda yapışkan); dar sütunda taşmaya karşı **container query** (`@container`, `@lg:grid-cols-4`). Frontend 81 test; e2e 29 kontrol (+ Adım 3'ün 31'i yeniden geçti). **Not:** e2e betikleri aynı DB'yi paylaşırsa birbirini bozar → her biri temiz DB ile. - **Adım 5:** Yedekleme betikleri: `scripts/backup.sh` (Swarm etiketiyle konteyneri bulur, `mongodump --archive --gzip` → `.partial` → `gzip -t` + `mongorestore --dryRun` → ad verilir; son `KEEP=14`, `BACKUP_GROUP` ile okuma izni), `scripts/restore.sh` (dosya adından kaynak DB, `--nsFrom/--nsTo` ile **başka adla**; mevcut hedefe yalnızca `--force` + `--drop`; `COMPARE_DB` ile sayım karşılaştırma), `scripts/backup-pull.sh` (rsync SSH BatchMode, `~/Yedekler/kakeibo`, son 60, 48 saatten eski → `notify-send`). `deploy/systemd/` (sunucu 03:30, bu bilgisayar 09:00 + açılış, `Persistent=true`). Kullanıcı kararı: sunucu dışı kopya **bu bilgisayara** (S3 değil). `npm run db:backup` / `db:restore` (yerel, `backups/` gitignore). Yerel test: yedek → geri yükleme sayıları kakeibo_dev ile aynı, koruma, bozuk dosya hatası, saklama ✅. + +### 2026-11-12 — Faz 6 +- **Adım 1:** Değerlendirme backend: `services/review.rs` (`budget::summary` + `budget::view` + kategori aggregation), `routes/review.rs` → `GET /periods/{id}/review` (`PeriodReview`: `available` = gelir − sabit − borç + artı, `savings_goal`, `spent_total`, `realized_savings` = available − harcanan, `savings_gap`; `groups` planned/spent, `categories` çoktan aza (+count), `weeks` 4 grubun toplamı). Kapalı dönemde okunur. `periods.reflection_md` (PATCH, ≤ 20.000 karakter, `""` → null, yalnızca açık dönem). `tests/review.rs` (3). Mutasyon: 4. soruya hedef düşmek / silinenleri saymak → yakalandı. Not: `TestApp::register` adı e-postaya çevirir → Türkçe karakterli ad kullanma. diff --git a/docs/PHASES.md b/docs/PHASES.md index 28e921c..2e845d0 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -10,7 +10,7 @@ | **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | ✅ | | **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 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 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 | ⏳ | @@ -59,3 +59,10 @@ - ✅ 3. Frontend: Defter — hafta seçici + hızlı giriş formu (tutar, 4 grup düğmesi, kategori, ödeme türü, tarih, açıklama; son seçim hatırlanır) - ✅ 4. Frontend: haftanın girişleri (gün gün), düzenle/sil + "Geri al", haftalık özet - 🔄 5. Canlı yedekleme (betikler + test ✅, sunucuda kurulum kullanıcıda) (mongodump günlük, 14 gün) + geri yükleme denemesi + +## Faz 6 Adımları (taslak) +- ✅ 1. Backend: değerlendirme (`GET /periods/{id}/review`: 4 soru, grup/kategori/hafta) + `reflection_md` +- ⏳ 2. Backend: dönem kapatma (transaction: durum, devir, birikime aktarılan tutar) + yeni dönemde devrin bağlanması + yeniden açma (sahip) +- ⏳ 3. Frontend: Değerlendirme sayfası (4 soru, planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım, "Nasıl daha iyi yapabilirim?", notlar) +- ⏳ 4. Frontend: dönem kapatma sihirbazı (+ yeni dönemi kopyalayarak açma) +- ⏳ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip) diff --git a/frontend/src/lib/plan-draft.test.ts b/frontend/src/lib/plan-draft.test.ts index 1768b76..f8497ad 100644 --- a/frontend/src/lib/plan-draft.test.ts +++ b/frontend/src/lib/plan-draft.test.ts @@ -13,6 +13,7 @@ const period: Period = { days: 31, status: 'open', savings_goal: 500_000, + reflection_md: null, version: 3, weeks: [ { index: 1, start_date: '2026-12-01', last_date: '2026-12-06', days: 6 }, diff --git a/frontend/src/types/Period.ts b/frontend/src/types/Period.ts index 6149d99..84efdfa 100644 --- a/frontend/src/types/Period.ts +++ b/frontend/src/types/Period.ts @@ -18,4 +18,8 @@ last_date: string, days: number, status: PeriodStatus, /** * Kuruş */ -savings_goal: number, version: number, weeks: Array, }; +savings_goal: number, +/** + * "Nasıl daha iyi yapabilirim?" (Markdown) + */ +reflection_md: string | null, version: number, weeks: Array, }; diff --git a/frontend/src/types/PeriodReview.ts b/frontend/src/types/PeriodReview.ts new file mode 100644 index 0000000..a42bdb7 --- /dev/null +++ b/frontend/src/types/PeriodReview.ts @@ -0,0 +1,38 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ReviewCategory } from "./ReviewCategory"; +import type { ReviewGroup } from "./ReviewGroup"; +import type { ReviewWeek } from "./ReviewWeek"; + +export type PeriodReview = { income_total: number, fixed_expense_total: number, carryover_debt: number, carryover_surplus: number, +/** + * 1. Ne kadar param vardı? (gelir − sabit gider − borç + artı) + */ +available: number, +/** + * 2. Ne kadar biriktirmek istedim? + */ +savings_goal: number, +/** + * 3. Ne kadar harcadım? + */ +spent_total: number, +/** + * 4. Ne kadar biriktirdim? (negatifse açık var) + */ +realized_savings: number, +/** + * Gerçekleşen − hedef birikim (negatifse hedefin altında) + */ +savings_gap: number, +/** + * 4 grup, sabit sırada + */ +groups: Array, +/** + * Harcama olan kategoriler, çoktan aza + */ +categories: Array, +/** + * Haftalar: 4 grubun toplamı + */ +weeks: Array, }; diff --git a/frontend/src/types/PeriodUpdateRequest.ts b/frontend/src/types/PeriodUpdateRequest.ts index 301faa0..7b35d16 100644 --- a/frontend/src/types/PeriodUpdateRequest.ts +++ b/frontend/src/types/PeriodUpdateRequest.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PeriodUpdateRequest = { name?: string, start_date?: string, end_date?: string, savings_goal?: number, version?: number, }; +export type PeriodUpdateRequest = { name?: string, start_date?: string, end_date?: string, savings_goal?: number, +/** + * "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır + */ +reflection_md?: string, version?: number, }; diff --git a/frontend/src/types/ReviewCategory.ts b/frontend/src/types/ReviewCategory.ts new file mode 100644 index 0000000..21b4a75 --- /dev/null +++ b/frontend/src/types/ReviewCategory.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Group } from "./Group"; + +export type ReviewCategory = { category_id: string, group: Group, spent: number, +/** + * Kayıt sayısı + */ +count: number, }; diff --git a/frontend/src/types/ReviewGroup.ts b/frontend/src/types/ReviewGroup.ts new file mode 100644 index 0000000..637f645 --- /dev/null +++ b/frontend/src/types/ReviewGroup.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Group } from "./Group"; + +export type ReviewGroup = { group: Group, +/** + * Aylık bütçe + */ +planned: number, spent: number, }; diff --git a/frontend/src/types/ReviewWeek.ts b/frontend/src/types/ReviewWeek.ts new file mode 100644 index 0000000..2d4c724 --- /dev/null +++ b/frontend/src/types/ReviewWeek.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ReviewWeek = { index: number, start_date: string, last_date: string, planned: number, spent: number, };