diff --git a/backend/api.http b/backend/api.http index 14a3768..9a00428 100644 --- a/backend/api.http +++ b/backend/api.http @@ -324,3 +324,11 @@ Content-Type: application/json ### Kapalı dönemi düzeltmeye aç (yalnızca hane sahibi) → status: correcting; düzeltince yeniden "close" POST {{baseUrl}}/periods/BURAYA-DONEM-ID/reopen + +# ─────────────────────────── Panel ─────────────────────────── + +### Günlük harcama: dönemin her günü, 4 grup (harcama yoksa 0) +GET {{baseUrl}}/periods/BURAYA-DONEM-ID/daily + +### Son dönemler (eskiden yeniye): harcanabilir, harcanan, birikime aktarılan + toplam birikim +GET {{baseUrl}}/stats/periods?limit=12 diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index f08f6ae..11d2a5a 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -11,6 +11,7 @@ mod period; mod plan; mod review; mod spa; +mod stats; use axum::{ Router, @@ -82,6 +83,8 @@ pub fn router(state: AppState) -> Router { .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}/daily", get(stats::daily)) + .route("/stats/periods", get(stats::periods)) .route( "/periods/{id}/budget", get(budget::get).put(budget::set_monthly), diff --git a/backend/src/routes/stats.rs b/backend/src/routes/stats.rs new file mode 100644 index 0000000..a7523c4 --- /dev/null +++ b/backend/src/routes/stats.rs @@ -0,0 +1,113 @@ +//! Panel (Dashboard) uç noktaları (hanenin tüm üyeleri): +//! +//! | Yöntem | Yol | İş | +//! |---|---|---| +//! | GET | `/api/periods/{id}/daily` | dönemin her günü: 4 grubun harcaması | +//! | GET | `/api/stats/periods?limit=12` | son dönemler (eskiden yeniye): harcanabilir, harcanan, birikime aktarılan + toplam birikim | + +use axum::{ + Json, + extract::{Path, Query, State}, +}; +use mongodb::bson::Uuid; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::{ + auth::AuthUser, + error::{AppError, AppResult}, + models::period::PeriodStatus, + routes::budget::GroupAmounts, + services::{ + calendar::{format_date, parse_date}, + stats, + }, + state::AppState, +}; + +#[derive(Serialize, TS)] +#[ts(export, rename = "DaySpent")] +pub struct DayDto { + /// `"YYYY-MM-DD"` + date: String, + /// O gün gruplara göre harcanan (kuruş) + spent: GroupAmounts, +} + +pub async fn daily( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult>> { + let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?; + let days = stats::daily(&s.db, auth.household_id, id).await?; + Ok(Json( + days.into_iter() + .map(|d| DayDto { + date: d.date, + spent: d.spent.into(), + }) + .collect(), + )) +} + +#[derive(Deserialize)] +pub struct StatsQuery { + limit: Option, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "PeriodStats")] +pub struct StatsDto { + /// Kapanışlarda birikime aktarılanların toplamı (kuruş) + #[ts(type = "number")] + total_saved: i64, + /// Eskiden yeniye + periods: Vec, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "PeriodStat")] +struct PeriodStatDto { + id: String, + name: String, + start_date: String, + /// Son gün, dahil + last_date: String, + status: PeriodStatus, + #[ts(type = "number")] + spendable: i64, + #[ts(type = "number")] + spent: i64, + /// Kapanmamışsa `null` + #[ts(type = "number | null")] + to_savings: Option, +} + +pub async fn periods( + State(s): State, + auth: AuthUser, + Query(q): Query, +) -> AppResult> { + let st = stats::periods(&s.db, auth.household_id, q.limit.unwrap_or(12)).await?; + Ok(Json(StatsDto { + total_saved: st.total_saved, + periods: st + .periods + .into_iter() + .map(|x| { + let end = parse_date(&x.period.end_date).expect("geçerli bitiş tarihi"); + PeriodStatDto { + id: x.period.id.to_string(), + name: x.period.name, + start_date: x.period.start_date, + last_date: format_date(end - time::Duration::days(1)), + status: x.period.status, + spendable: x.spendable, + spent: x.spent, + to_savings: x.period.to_savings, + } + }) + .collect(), + })) +} diff --git a/backend/src/services/budget.rs b/backend/src/services/budget.rs index 3393652..21e1a7e 100644 --- a/backend/src/services/budget.rs +++ b/backend/src/services/budget.rs @@ -485,7 +485,7 @@ async fn sum_by( } /// Dönemin harcamaları (grup, gün) bazında toplanmış: en fazla 4 × 62 satır. -async fn spent_by_group_and_date( +pub(crate) async fn spent_by_group_and_date( db: &Database, household_id: Uuid, period_id: Uuid, diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 2b9df51..af91772 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -13,3 +13,4 @@ pub mod period; pub mod plan; pub mod review; pub mod seed; +pub mod stats; diff --git a/backend/src/services/stats.rs b/backend/src/services/stats.rs new file mode 100644 index 0000000..6d216c0 --- /dev/null +++ b/backend/src/services/stats.rs @@ -0,0 +1,88 @@ +//! Panel (Dashboard) verileri (KAKEIBO_PROMPT §8.2). Hiçbiri saklanmaz; her istekte toplanır. +//! +//! - `daily`: dönemin her günü için 4 grubun harcaması (harcama olmayan gün 0). +//! Günlük/haftalık ve kümülatif seriler istemcide bundan türetilir. +//! - `periods`: son N dönemin harcanabilir/harcanan tutarı (aylık grafik) ve +//! **toplam birikim** = kapanışlarda birikime aktarılanların toplamı +//! (varlık değerleri Faz 8'de bunun yerini alacak). Yeniden açılan dönemin son +//! kapanıştaki tutarı, yeniden kapatılana kadar sayılır. + +use mongodb::{Database, bson::Uuid}; + +use crate::{ + error::AppResult, + models::period::Period, + services::{ + budget::{self, GroupAmounts}, + calendar::{format_date, parse_date}, + period, + }, +}; + +/// Aylık grafikte en fazla dönem sayısı +pub const MAX_PERIODS: usize = 24; + +pub struct DaySpent { + /// `"YYYY-MM-DD"` + pub date: String, + /// `Group::ALL` sırasıyla + pub spent: GroupAmounts, +} + +pub async fn daily(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult> { + let p = period::get(db, household_id, period_id).await?; + // Veritabanındaki tarihler her zaman doğrulanmış biçimde yazılır + let start = parse_date(&p.start_date).expect("geçerli başlangıç tarihi"); + let end = parse_date(&p.end_date).expect("geçerli bitiş tarihi"); + + let mut days = Vec::new(); + let mut d = start; + while d < end { + days.push(DaySpent { + date: format_date(d), + spent: [0; 4], + }); + d += time::Duration::days(1); + } + for (group, date, amount) in + budget::spent_by_group_and_date(db, household_id, period_id).await? + { + // Dönem tarihleri sonradan daraltıldıysa dışarıda kalan kayıt grafiğe girmez + if let Some(day) = days.iter_mut().find(|x| x.date == date) { + day.spent[group.order()] += amount; + } + } + Ok(days) +} + +pub struct PeriodStat { + pub period: Period, + pub spendable: i64, + pub spent: i64, +} + +pub struct Stats { + /// Kapanışlarda birikime aktarılanların toplamı (tüm dönemler) + pub total_saved: i64, + /// Son `limit` dönem, **eskiden yeniye** + pub periods: Vec, +} + +pub async fn periods(db: &Database, household_id: Uuid, limit: usize) -> AppResult { + let list = period::list(db, household_id).await?; // en yeni başta + let total_saved = list.iter().filter_map(|p| p.to_savings).sum(); + let mut out = Vec::new(); + for p in list.into_iter().take(limit.clamp(1, MAX_PERIODS)) { + let s = budget::summary(db, household_id, p.id).await?; + out.push(PeriodStat { + period: p, + spendable: s.spendable, + spent: s.spent_total, + }); + } + out.reverse(); + Ok(Stats { + total_saved, + periods: out, + }) +} diff --git a/backend/tests/stats.rs b/backend/tests/stats.rs new file mode 100644 index 0000000..82a1287 --- /dev/null +++ b/backend/tests/stats.rs @@ -0,0 +1,164 @@ +//! Panel verileri: günlük harcama ve dönem istatistikleri (toplam birikim dahil). + +mod common; + +use axum::http::StatusCode; +use common::{Session, TestApp}; +use serde_json::{Value, json}; + +async fn open(app: &TestApp, s: &Session, start: &str, end: &str, goal: i64) -> String { + let (st, p) = app + .post( + "/periods", + s, + json!({ "start_date": start, "end_date": end, "savings_goal": goal }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{p}"); + p["id"].as_str().unwrap().to_string() +} + +async fn category(app: &TestApp, s: &Session, group: &str) -> String { + let (_, cats) = app.get("/categories", s).await; + cats.as_array() + .unwrap() + .iter() + .find(|c| c["group"] == group) + .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 income(app: &TestApp, s: &Session, pid: &str, amount: i64) { + let (st, b) = app + .post( + &format!("/periods/{pid}/plan-items"), + s, + json!({ "kind": "income", "name": "Maaş", "amount": amount }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{b}"); +} + +/// Kasım: gelir 10.000, harcama 3.000, 5.000 birikime → 2.000 artı Aralık'a. +/// Aralık: gelir 20.000, hedef 1.000 → harcanabilir 21.000. +async fn two_periods(app: &TestApp, s: &Session) -> (String, String) { + let needs = category(app, s, "needs").await; + let nov = open(app, s, "2026-11-01", "2026-12-01", 0).await; + income(app, s, &nov, 1_000_000).await; + spend(app, s, &nov, "2026-11-30", 300_000, &needs).await; + let (st, b) = app + .post( + &format!("/periods/{nov}/close"), + s, + json!({ "to_savings": 500_000 }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{b}"); + let dec = open(app, s, "2026-12-01", "2027-01-01", 100_000).await; + income(app, s, &dec, 2_000_000).await; + (nov, dec) +} + +#[tokio::test] +async fn daily_spending_by_group() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let (_, dec) = two_periods(&app, &s).await; + let needs = category(&app, &s, "needs").await; + let wants = category(&app, &s, "wants").await; + spend(&app, &s, &dec, "2026-12-01", 100_000, &needs).await; + spend(&app, &s, &dec, "2026-12-01", 50_000, &wants).await; + spend(&app, &s, &dec, "2026-12-07", 20_000, &needs).await; + spend(&app, &s, &dec, "2026-12-31", 7_000, &needs).await; + let gone = spend(&app, &s, &dec, "2026-12-02", 99_000, &needs).await; + let (st, _) = app + .delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s) + .await; + assert_eq!(st, StatusCode::NO_CONTENT); + + let (st, days) = app.get(&format!("/periods/{dec}/daily"), &s).await; + assert_eq!(st, StatusCode::OK, "{days}"); + let days = days.as_array().unwrap(); + assert_eq!(days.len(), 31, "her gün, harcama olmasa da"); + assert_eq!(days[0]["date"], "2026-12-01"); + assert_eq!( + days[0]["spent"], + json!({ "needs": 100_000, "wants": 50_000, "culture": 0, "unexpected": 0 }) + ); + assert_eq!(days[1]["spent"]["needs"], 0, "silinen kayıt sayılmaz"); + assert_eq!(days[6]["spent"]["needs"], 20_000); + assert_eq!(days[30]["date"], "2026-12-31"); + assert_eq!(days[30]["spent"]["needs"], 7_000, "son gün dahil"); + // Kasım'ın 30.11 harcaması Aralık'a karışmaz + let total: i64 = days + .iter() + .map(|d| d["spent"]["needs"].as_i64().unwrap()) + .sum(); + assert_eq!(total, 127_000); + + // Başka hane göremez + let other = app.register("Diger", None).await; + let (st, _) = app.get(&format!("/periods/{dec}/daily"), &other).await; + assert_eq!(st, StatusCode::NOT_FOUND); + app.cleanup().await; +} + +#[tokio::test] +async fn period_stats_and_total_savings() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let (nov, dec) = two_periods(&app, &s).await; + let needs = category(&app, &s, "needs").await; + spend(&app, &s, &dec, "2026-12-05", 40_000, &needs).await; + + let (st, b) = app.get("/stats/periods", &s).await; + assert_eq!(st, StatusCode::OK, "{b}"); + assert_eq!(b["total_saved"], 500_000); + let list = b["periods"].as_array().unwrap(); + assert_eq!(list.len(), 2); + // Eskiden yeniye + assert_eq!(list[0]["id"], nov.as_str()); + assert_eq!(list[0]["status"], "closed"); + assert_eq!(list[0]["spendable"], 1_000_000); + assert_eq!(list[0]["spent"], 300_000); + assert_eq!(list[0]["to_savings"], 500_000); + assert_eq!(list[0]["last_date"], "2026-11-30"); + assert_eq!(list[1]["id"], dec.as_str()); + assert_eq!( + list[1]["spendable"], 2_100_000, + "20.000 − 1.000 hedef + 2.000 artı" + ); + assert_eq!(list[1]["spent"], 40_000); + assert_eq!(list[1]["to_savings"], Value::Null); + + // limit: en yeni N dönem + let (_, b) = app.get("/stats/periods?limit=1", &s).await; + let list = b["periods"].as_array().unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0]["id"], dec.as_str()); + assert_eq!( + b["total_saved"], 500_000, + "toplam birikim limitten bağımsız" + ); + + // Başka hane: boş + let other = app.register("Diger", None).await; + let (_, b) = app.get("/stats/periods", &other).await; + assert_eq!(b["total_saved"], 0); + assert_eq!(b["periods"], json!([])); + app.cleanup().await; +} diff --git a/docs/MEMORY.md b/docs/MEMORY.md index d55f4ce..5049c50 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 7 – Adım 1:** Dashboard planı — önce `KAKEIBO_PROMPT.md` Dashboard bölümünü oku, adımları `docs/PHASES.md`'ye yaz ve kullanıcıya onaylat (özet kartları, grafikler; Recharts). +**Faz 7 – Adım 2:** Panel özet kartları (`features/dashboard/`): gelir, sabit gider, toplam birikim (`/stats/periods` → `total_saved`; "varlık değerleri Faz 8'de" notu), devreden borç (varsa), grup planlanan/gerçekleşen (Haftalık/Aylık geçişi, `useBudget`), **anlık durum** = kalan harcanabilir (en belirgin). Mobilde yüzen "➕ Harcama Ekle" → Defter (tutara odak). Skeleton + boş durumlar. Açık dönem yoksa son dönem salt okunur + "Yeni dönemi aç". **Ardından sorulacak:** Ayarlar'da kategori/ödeme türü yönetimi ekranı hiçbir fazda planlı değil (kullanıcı Defter'de kategorileri kısıtlı buldu; 3. kat alt kategori istiyor mu?). **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 @@ -112,3 +112,7 @@ - **Adım 3:** Değerlendirme sayfası (`features/review/`): `Questions` (4 kart; 1. kart hesap açıklaması, 3. bütçe yüzdesi, 4. hedefe göre durum `lib/review.ts` → `savingsStatus`: above/below/deficit), `breakdowns.tsx` (`GroupComparison`, `TopCategories` ilk 5 + "Tümünü göster", `WeeklyDistribution`; ortak `Bar`, `ratio`), `Reflection` (görüntüle/düzenle, `useUpdatePeriod` + `version`, 409 formda; şema `features/review/schema.ts`), `NotesSection collapsed` (yalnız başlık, `aria-expanded` ile açılır). Ortak `components/markdown-tabs.tsx` (Yaz/Önizle, `useId` kimlikler; `NoteEditor` da kullanıyor). `useReview` + `queryKeys.review` (dönem önekinde → kayıt değişince yenilenir). Dönem sürüyorsa bilgi satırı. Frontend 84 test; e2e 24 kontrol (Ekim kapatılıp Kasım'a artı devri dahil). Mobil haftalık satır sıkıştırıldı. - **Adım 4:** Kapatma sihirbazı `/degerlendirme/kapat` (`features/review/close-page.tsx`): Özet (4 soru, erken kapatma uyarısı, "Nasıl daha iyi" boşsa uyarı + bağlantı) → Birikim (artıda `MoneyInput` varsayılan tamamı, Tamamı/Hiçbiri, canlı önizleme; açıkta/sıfırda yalnız bilgi) → Onay (`useClosePeriod`, `version`; 409 formda, `onSettled` tüm `['period']` + liste yenilenir → ikinci deneme güncel sürümle) → Sonuç (sayfa durumunda; kapanınca `current` null). `lib/review.ts` → `settlePreview` (backend `settle` ile aynı tutulmalı), şema `close-schema.ts` (0..gerçekleşen). `Stepper` artık `steps`/`label` alıyor. Dönem açma sihirbazı tarih önerisi: `suggestRanges(today, prevEnd)` / `rangeAfter` — son dönemin bitişinden başlar, çakışan ay önerilmez (erken kapatılan ay tekrar önerilmezdi hatası önlendi). Not: `moneyToText(0)` → `''` (plan taslağı için); 0 göstermek gerekiyorsa `formatNumber`. Frontend 89 test; e2e 33 kontrol (DB silindi, 8091 kapatıldı). - **Adım 5:** Dönem seçimi: `lib/period-select.ts` (`useSelectedPeriod`: `?donem=` → listeden; yoksa açık dönem, o da yoksa düzeltmedeki; `pickPeriod`, `isWritable`, `canReopen` = backend kuralı, `periodPath`), `features/periods/` (`PeriodPicker` başlıkta `