diff --git a/CLAUDE.md b/CLAUDE.md index 2592049..b8b4df6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ npm run db:restore -- [--force] - Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`. - Excel okuma: SheetJS **yerel paket** `frontend/vendor/xlsx-0.20.3.tgz` (npm 12 uzak tarball'u engeller; npm'deki `xlsx` eski/açıklı). Güncellerken: cdn.sheetjs.com'dan indir, içeriği/sha256'yı kontrol et, `npm i ./vendor/…`; Dockerfile `vendor/`'u `npm ci`'den önce kopyalar. Lazy yüklenir. - Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç. -- JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. +- JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir). - Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404. ## Ortam diff --git a/docs/MEMORY.md b/docs/MEMORY.md index e91069d..3048559 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 9 – Adım 5b:** JSON yedeği arayüzü (Ayarlar → Veri): "Yedeği indir" (son yedek X gün önce / hiç alınmadı hatırlatması), sahip için "Yedekten geri yükle" → dosya seç → önizleme (kaynak hane, tarih, kayıt sayıları yedek/şimdi, tarih aralığı, uyarılar) → onay kutusu → geri yükle → sonuç + "Geri al"; geri dönüş noktaları listesi (tarih, neden, kayıt sayısı, "Bu duruma dön" onaylı). Tipler hazır: `BackupStatus`, `BackupPreview`, `BackupCount`, `RestorePoint`, `RestoreResult`. Gövde: dosya metni (`api.postCsv` benzeri, `application/json`). Sonra Adım 6 aylık rapor. +**Faz 9 – Adım 6:** Aylık değerlendirme çıktısı — `templates/monthly_report.md`, Claude tasarlar (müşteri gözüyle: ne yaptım / ne iyi-kötü gitti / ne yapılabilirdi / ne yapmalıydım / gelecek ay önerileri). Önce taslak yapıyı kullanıcıya sun (bölümler, hangi veriden), sonra Markdown üretimi + yazdır/PDF. **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 @@ -143,3 +143,4 @@ - **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). - **Faz 9 – Adım 5a (2026-11-13):** `services/backup.rs` + `routes/backup.rs` (`/backup/download|status|preview|restore`, `/backup/restore-points/{id}/restore`; preview/restore/undo **sahip**, 20 MB). Biçim ham BSON belgeleri üzerinden (modeller eksik alanlı olduğu için tür tabanlı değil): `$uuid`/`$date`, `INT32_KEYS` (version, sort_order) + ayarlar i32 (`small_ints`), SHA-256 kanonik (sıralı anahtar) → yeniden biçimlendirilmiş dosya geçerli, değiştirilmiş reddedilir. **Silinmiş kayıtlar dahil** (tasarımdan sapma: referans bütünlüğü + birebir geri al). Migration v3 `restore_points` (transaction içinde ilk yazım için) + indeks. `flate2`, `sha2` doğrudan eklendi (zaten bağımlılık ağacında). Testler: 4 birim + 3 entegrasyon (aynı hane + geri al zinciri + 3 nokta sınırı, başka haneye iki kez kopya, bozuk/yabancı/yeni sürüm dosya, üye 403, başka hane 404). 3 bilinçli bozma yakalandı. Backend 190 test. +- **Faz 9 – Adım 5b (2026-11-13):** `features/settings/backup-section.tsx` (Veri bölümünün en üstü): "Yedeği indir" (``, tıklayınca 1,5 sn sonra durum yenilenir) + hatırlatma (`daysSince`, hiç yok ya da ≥ 30 gün → uyarı rengi); sahip: dosya → önizleme (kaynak, aynı/başka hane, dönem aralığı, sayılar yedek/şimdi — farklı olan uyarı rengi, uyarılar) → onay kutusu (`#backup-confirm`; Radix `name`'i düğmeye taşımaz) → "Geri yükle" (destructive) → sonuç kutusu + "Geri al"; geri dönüş noktaları listesi (`window.confirm`). Üye: yalnız indirme + "yalnız sahip" notu. `lib/backup.ts` (geri yükleme/geri alma → `qc.invalidateQueries()` tümü), `api.postJsonText`, `queryKeys.backup`, `t.backup`. Frontend 134 test; e2e 27 kontrol (8091, DB silindi). diff --git a/docs/PHASES.md b/docs/PHASES.md index eaaeef5..2e8de15 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -83,5 +83,5 @@ - ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır - ✅ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu - ✅ 4. **Banka / kredi kartı ekstresi içe aktarma** — 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) + 4b arayüz (`/ayarlar/ekstre`: Excel/CSV, sütun eşleştirme + profil, gözden geçirme) -- 🔄 5. JSON tam hane yedeği — ✅ 5a backend (sürümlü dosya + SHA-256, önizleme, tam değiştirme tek transaction, geri dönüş noktaları + "geri al", başka haneye kimlik çevirerek taşıma) · ⏳ 5b arayüz (Ayarlar → Veri) +- ✅ 5. JSON tam hane yedeği — 5a backend (sürümlü dosya + SHA-256, önizleme, tam değiştirme tek transaction, geri dönüş noktaları + "geri al", başka haneye kimlik çevirerek taşıma) · 5b arayüz (Ayarlar → Veri: indir + "son yedek" hatırlatması, sahip için önizleme → onay → geri yükle → "Geri al", geri dönüş noktaları) - ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF diff --git a/frontend/src/features/settings/backup-section.tsx b/frontend/src/features/settings/backup-section.tsx new file mode 100644 index 0000000..cdf8565 --- /dev/null +++ b/frontend/src/features/settings/backup-section.tsx @@ -0,0 +1,342 @@ +import { useRef, useState } from 'react' +import { + CircleCheck, + Download, + FileUp, + History, + Loader2, + RotateCcw, + TriangleAlert, +} from 'lucide-react' +import { toast } from 'sonner' +import { FormError } from '@/components/form-field' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Section } from '@/features/planning/plan-read' +import { useMe } from '@/lib/auth' +import { + BACKUP_DOWNLOAD_URL, + daysSince, + MAX_BACKUP_BYTES, + STALE_DAYS, + useBackupPreview, + useBackupStatus, + useRestoreBackup, + useUndoRestore, +} from '@/lib/backup' +import { queryKeys } from '@/lib/query' +import { formatDate, formatDateTime } from '@/lib/format' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' +import type { BackupPreview } from '@/types/BackupPreview' +import type { RestorePoint } from '@/types/RestorePoint' +import { useQueryClient } from '@tanstack/react-query' + +const s = t.backup +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)) + +/** Ayarlar → Veri: JSON yedeği indir; sahip için geri yükle + geri dönüş noktaları */ +export function BackupSection() { + const me = useMe() + const status = useBackupStatus() + const isOwner = me.data?.user.role === 'owner' + + return ( +
+
+ + {isOwner ? ( + <> + + {status.data && status.data.restore_points.length > 0 && ( + + )} + + ) : ( + me.data &&

{s.ownerOnly}

+ )} +
+
+ ) +} + +function DownloadPart({ lastBackupAt, loaded }: { lastBackupAt: string | null; loaded: boolean }) { + const qc = useQueryClient() + const [now] = useState(() => new Date()) + const days = daysSince(lastBackupAt, now) + const stale = days === null || days >= STALE_DAYS + return ( +
+

{s.hint}

+
+ + {loaded && ( +

+ {stale && } + {days === null + ? s.never + : days === 0 + ? s.today + : s.daysAgo(days, formatDate(lastBackupAt!))} + {stale && days !== null && ` · ${s.stale}`} +

+ )} +
+
+ ) +} + +function RestorePart() { + const input = useRef(null) + const [file, setFile] = useState<{ name: string; text: string } | null>(null) + const [preview, setPreview] = useState(null) + const [agreed, setAgreed] = useState(false) + const [error, setError] = useState() + const [pointId, setPointId] = useState(null) + const check = useBackupPreview() + const restore = useRestoreBackup() + const undo = useUndoRestore() + + function reset() { + setFile(null) + setPreview(null) + setAgreed(false) + setError(undefined) + if (input.current) input.current.value = '' + } + + async function choose(f: File) { + reset() + setPointId(null) + if (f.size > MAX_BACKUP_BYTES) return setError(s.tooBig) + const text = await f.text() + setFile({ name: f.name, text }) + try { + setPreview(await check.mutateAsync(text)) + } catch (e) { + setError(message(e)) + } + } + + async function run() { + if (!file) return + setError(undefined) + try { + const r = await restore.mutateAsync(file.text) + reset() + setPointId(r.restore_point_id) + toast.success(s.restored) + } catch (e) { + setError(message(e)) + } + } + + async function revert(id: string) { + try { + await undo.mutateAsync(id) + setPointId(null) + toast.success(s.undone) + } catch (e) { + setError(message(e)) + } + } + + const busy = check.isPending || restore.isPending || undo.isPending + return ( +
+

{s.restoreTitle}

+

{s.restoreHint}

+ + {pointId && ( +
+ +
+

{s.restored}

+

{s.restoredHint}

+
+ +
+ )} + +
+ { + const f = e.target.files?.[0] + if (f) void choose(f) + }} + /> + + {file && {file.name}} +
+ + {check.isPending && ( +

+ + {s.checking} +

+ )} + + + {preview && ( +
+
+

+ {s.source(preview.source_name, formatDateTime(preview.exported_at))} +

+

+ {preview.same_household ? s.sameHousehold : s.otherHousehold} + {preview.first_date && + preview.last_date && + ` · ${s.range(formatDate(preview.first_date), formatDate(preview.last_date))}`} +

+
+ + + + + + + + + + + {preview.counts + .filter((c) => c.backup > 0 || c.current > 0) + .map((c) => ( + + + + + + ))} + +
{s.colKind}{s.colBackup}{s.colCurrent}
{c.label}{c.backup} + {c.current} +
+ + {preview.warnings.length > 0 && ( +
    + {preview.warnings.map((w) => ( +
  • + + {w} +
  • + ))} +
+ )} + + +
+ + +
+
+ )} +
+ ) +} + +function Points({ points, keep }: { points: RestorePoint[]; keep: [number, number] }) { + const undo = useUndoRestore() + const [error, setError] = useState() + + async function go(p: RestorePoint) { + if (!window.confirm(s.confirmUndo(formatDateTime(p.created_at)))) return + setError(undefined) + try { + await undo.mutateAsync(p.id) + toast.success(s.undone) + } catch (e) { + setError(message(e)) + } + } + + return ( +
+

+ + {s.pointsTitle} +

+

{s.pointsHint(...keep)}

+
    + {points.map((p) => ( +
  • +
    +

    {formatDateTime(p.created_at)}

    +

    + {s.reason[p.reason]} · {s.pointCounts(p.periods, p.entries)} +

    +
    + +
  • + ))} +
+ +
+ ) +} diff --git a/frontend/src/features/settings/data-section.tsx b/frontend/src/features/settings/data-section.tsx index ae75074..25f3bbf 100644 --- a/frontend/src/features/settings/data-section.tsx +++ b/frontend/src/features/settings/data-section.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { Link } from 'react-router' import { paths } from '@/components/layout/nav-items' import { Section } from '@/features/planning/plan-read' +import { BackupSection } from './backup-section' import { decodeText } from '@/lib/decode' import { t } from '@/lib/strings' import { exportUrl, TRANSFER_KINDS, useImport, type TransferKind } from '@/lib/transfer' @@ -24,6 +25,7 @@ const MAX_ERRORS = 50 export function DataSection() { return (
+

{s.exportHint}

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d0eed0e..f1c93c1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -79,4 +79,7 @@ export const api = { /** CSV metnini gövde olarak gönderir */ postCsv: (path: string, csv: string) => request('POST', path, new TextBody(csv, 'text/csv; charset=utf-8')), + /** Hazır JSON metni (ör. yedek dosyası) olduğu gibi gönderilir */ + postJsonText: (path: string, json: string) => + request('POST', path, new TextBody(json, 'application/json; charset=utf-8')), } diff --git a/frontend/src/lib/backup.test.ts b/frontend/src/lib/backup.test.ts new file mode 100644 index 0000000..5820f5a --- /dev/null +++ b/frontend/src/lib/backup.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { daysSince } from './backup' + +describe('backup reminder', () => { + it('counts calendar days since the last backup', () => { + const now = new Date(2026, 10, 13, 9, 0) + expect(daysSince(null, now)).toBeNull() + expect(daysSince(new Date(2026, 10, 13, 0, 5).toISOString(), now)).toBe(0) + expect(daysSince(new Date(2026, 10, 12, 23, 59).toISOString(), now)).toBe(1) // dün gece + expect(daysSince(new Date(2026, 9, 14, 12, 0).toISOString(), now)).toBe(30) + expect(daysSince(new Date(2026, 10, 14).toISOString(), now)).toBe(0) // saat farkı → eksi olmaz + }) +}) diff --git a/frontend/src/lib/backup.ts b/frontend/src/lib/backup.ts new file mode 100644 index 0000000..649378d --- /dev/null +++ b/frontend/src/lib/backup.ts @@ -0,0 +1,54 @@ +/** + * JSON tam hane yedeği (`/api/backup/*`). Geri yükleme ve geri alma hanenin tüm verisini + * değiştirdiği için başarıdan sonra **tüm** sorgular yenilenir. + */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { BackupPreview } from '@/types/BackupPreview' +import type { BackupStatus } from '@/types/BackupStatus' +import type { RestoreResult } from '@/types/RestoreResult' +import { api } from './api' +import { queryKeys } from './query' + +export const BACKUP_DOWNLOAD_URL = '/api/backup/download' +/** Sunucudaki sınırla aynı (`backup::MAX_BYTES`) */ +export const MAX_BACKUP_BYTES = 20 * 1024 * 1024 +/** Bu kadar günden eski yedek → hatırlatma vurgulanır */ +export const STALE_DAYS = 30 + +/** Son yedekten bu yana geçen tam gün (yerel takvim günü); hiç yoksa `null` */ +export function daysSince(iso: string | null, now: Date): number | null { + if (!iso) return null + const d = new Date(iso) + const day = (x: Date) => Date.UTC(x.getFullYear(), x.getMonth(), x.getDate()) + return Math.max(0, Math.round((day(now) - day(d)) / 86_400_000)) +} + +export function useBackupStatus() { + return useQuery({ + queryKey: queryKeys.backup, + queryFn: () => api.get('/backup/status'), + }) +} + +export function useBackupPreview() { + return useMutation({ + mutationFn: (json: string) => api.postJsonText('/backup/preview', json), + }) +} + +function useReplaceAll(fn: (v: V) => Promise) { + const qc = useQueryClient() + return useMutation({ + mutationFn: fn, + // Hane verisinin tamamı değişti → her şey yeniden okunur + onSuccess: () => qc.invalidateQueries(), + }) +} + +export const useRestoreBackup = () => + useReplaceAll((json: string) => api.postJsonText('/backup/restore', json)) + +export const useUndoRestore = () => + useReplaceAll((pointId: string) => + api.post(`/backup/restore-points/${pointId}/restore`, {}), + ) diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts index da0d448..17d7b8c 100644 --- a/frontend/src/lib/query.ts +++ b/frontend/src/lib/query.ts @@ -25,6 +25,8 @@ export const queryKeys = { /** Rapor kırılımı (`query` = sunucu sorgu metni); kayıt değişince `reports` öneki yenilenir */ reportBreakdown: (query: string) => ['reports', 'breakdown', query] as const, reports: ['reports'] as const, + /** JSON yedeği: son yedek zamanı + geri dönüş noktaları */ + backup: ['backup'] as const, period: (id: string) => ['period', id] as const, planItems: (id: string) => ['period', id, 'plan-items'] as const, summary: (id: string) => ['period', id, 'summary'] as const, diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 5757cd4..0b74ab2 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -576,6 +576,49 @@ export const t = { }, }, + backup: { + title: 'Yedek (JSON)', + hint: 'Hanenin tüm verisi tek dosyada: dönemler, plan, bütçe, harcamalar, notlar, birikimler, kategoriler. Şifreler ve hesaplar dosyaya girmez. Dosyada mali bilgileriniz olduğu için güvenli bir yerde saklayın.', + download: 'Yedeği indir', + never: 'Henüz hiç yedek almadınız.', + today: 'Son yedek: bugün', + daysAgo: (n: number, date: string) => `Son yedek: ${n} gün önce (${date})`, + stale: 'Ayda bir yedek almanızı öneririz.', + restoreTitle: 'Yedekten geri yükle', + restoreHint: + 'Önce dosya incelenir, hiçbir şey değişmez. Onaylarsanız mevcut verileriniz yedektekiyle değiştirilir; öncesindeki durum otomatik saklanır ve “Geri al” ile dönebilirsiniz.', + ownerOnly: 'Yedekten geri yükleme yalnızca hane sahibi tarafından yapılabilir.', + chooseFile: 'Yedek dosyası seç', + another: 'Başka dosya seç', + checking: 'Yedek inceleniyor…', + tooBig: 'Dosya çok büyük (en fazla 20 MB).', + source: (name: string, date: string) => `“${name}” hanesinin ${date} tarihli yedeği`, + sameHousehold: 'Bu hanenin yedeği', + otherHousehold: 'Başka bir hanenin yedeği', + range: (from: string, to: string) => `Dönemler: ${from} – ${to}`, + colKind: 'Kayıt', + colBackup: 'Yedekte', + colCurrent: 'Şu an', + confirm: 'Mevcut verilerimin yedektekiyle değiştirileceğini anladım.', + restore: 'Geri yükle', + restoring: 'Geri yükleniyor…', + cancel: 'Vazgeç', + restored: 'Yedek geri yüklendi.', + restoredHint: 'Önceki durum saklandı. Yanlışlıkla yaptıysanız hemen geri alabilirsiniz.', + undo: 'Geri al', + undone: 'Önceki duruma dönüldü.', + confirmUndo: (date: string) => + `Veriler ${date} tarihindeki duruma döndürülsün mü? Şu anki durum da saklanır; bu işlem de geri alınabilir.`, + pointsTitle: 'Geri dönüş noktaları', + pointsHint: (n: number, days: number) => + `Her geri yükleme ve geri almadan önce otomatik oluşur. Son ${n} nokta, en fazla ${days} gün saklanır.`, + reason: { + before_restore: 'Yedekten geri yüklemeden önce', + before_undo: 'Geri almadan önce', + }, + pointCounts: (periods: number, entries: number) => `${periods} dönem · ${entries} harcama`, + goBack: 'Bu duruma dön', + }, data: { title: 'Veri', exportTitle: 'Dışa aktar (CSV)',