feat(pwa): telefona kurulum, uygulama kabuğu önbelleği, güncelleme bildirimi, çevrimdışı uyarısı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 23:45:58 +03:00
parent 8ee3672d57
commit 5cbf97d884
14 changed files with 4701 additions and 17 deletions
+1
View File
@@ -86,6 +86,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--force]
- Aylık rapor: `services/monthly_report.rs` (`gather` → `Facts` → saf `analyze` → `render`), şablon `templates/monthly_report.md` (`include_str!`; Dockerfile `templates/`'u kopyalar). Filtreler `tl`/`pct`/`cell`. Şablonda ham HTML yok (arayüz göstermez). Arayüz `features/review/report-page.tsx` (`/degerlendirme/rapor?donem=`), `lib/monthly-report.ts`; `api.getText`; `Markdown` tabloları `.table-wrap` içinde kayar; yazdırma `@page A4` + `PageHeader printHidden`. Cümlelerde sayıdan sonra ek kullanma ("%429'u" gibi ünlü uyumu gerekir) → "X / Y", "kullanımı %N".
- Ayarlar: `/ayarlar` menü (+ tema) → alt sayfalar `SettingsSubpage` ("‹ Ayarlar"): `/ayarlar/kategoriler`, `/odeme-turleri`, `/butce` (varsayılan yüzdeler + öneri yöntemi), `/hane` (ad, üyeler, davet bağlantısı `/kayit?davet=`), `/hesap` (ad, şifre), `/veri` (yedek, CSV, ekstre). Kancalar `lib/household.ts`. Şifre değişince `users.sessions_valid_from` → eski jetonlar 401 (`AuthUser`). Katalog yönetimi `features/settings/catalog-editor.tsx` + `lib/catalog.ts` (`useCreateCatalog`/`useUpdateCatalog`/`useReorderCatalog`, `moveId`); sıralama isteği grubun **tüm** kimlikleri (pasifler dahil); grup sonradan değişmez.
- Erişilebilirlik kuralları: `--muted-foreground` açık temada 0.52 (gri zeminde AA); kart bileşenleri (`Section`, rapor bölümü) `min-w-0` — ızgara öğesi içeriğine uzayıp telefonda taşmasın; `Button` `sm`/`icon-sm` dokunmatikte (`pointer-coarse`) 44 px; tutarlar `formatMoney` ile **bölünmez boşluklu** (`1.250,00\u00a0₺`; testlerde `\u00a0`); bütçe çubuğu `budgetLevel` (≥ %80 turuncu + "Bütçe kullanımı %N", aşım kırmızı); `Markdown shiftHeadings` (sayfada tek h1). Cümlelerde sayıdan sonra ek yok.
- PWA: `vite.config.ts` `VitePWA` (registerType `prompt`, `/api` asla önbelleğe alınmaz, `navigateFallbackDenylist /api`, xlsx + yabancı font alt kümeleri ön önbellekte yok); `lib/pwa.tsx` (`PwaUpdater` "Yeni sürüm hazır → Yenile", saatlik yoklama; `OfflineBanner`), `lib/online.ts`. `main.tsx` `onlineManager.setOnline(navigator.onLine)`. Oturum sorgusu ağ hatası/duraklama → `RequireAuth` giriş sayfasına **atmaz**, "Tekrar dene" gösterir. Geliştirmede SW yok. DEPLOY §7.
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
## Ortam
+45 -9
View File
@@ -1,7 +1,8 @@
//! Derlenmiş arayüzün (frontend/dist) sunulması — tek sayfa uygulama (SPA).
//!
//! - `/assets/*` : adları içerik özetli (hash) → 1 yıl önbellek, `immutable`
//! - diğer dosyalar (favicon, ikonlar): 1 saat önbellek
//! - PWA: `sw.js`, `manifest.webmanifest` → hiç önbelleğe alınmaz (yeni sürüm hemen fark edilsin)
//! - diğer dosyalar (favicon, ikonlar, `workbox-<hash>.js`): 1 saat önbellek
//! - olmayan yol (`/defter`, `/plan` ...) → `index.html` (yönlendirmeyi React Router yapar)
//! - HTML hiç önbelleğe alınmaz → yeni sürüm yayınlanınca hemen yüklenir
@@ -24,19 +25,54 @@ pub fn router(dir: &Path) -> Router {
}
async fn cache_headers(req: Request, next: Next) -> Response {
let is_asset = req.uri().path().starts_with("/assets/");
let path = req.uri().path().to_string();
let mut res = next.run(req).await;
let is_html = res
.headers()
.get(header::CONTENT_TYPE)
.is_some_and(|v| v.as_bytes().starts_with(b"text/html"));
let policy = match (is_html, is_asset) {
(true, _) => "no-cache",
(false, true) => "public, max-age=31536000, immutable",
(false, false) => "public, max-age=3600",
};
res.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static(policy));
res.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(cache_policy(&path, is_html)),
);
res
}
/// Yol ve içerik türüne göre `Cache-Control`
fn cache_policy(path: &str, is_html: bool) -> &'static str {
const NO_CACHE: &str = "no-cache";
if is_html || matches!(path, "/sw.js" | "/manifest.webmanifest" | "/registerSW.js") {
NO_CACHE
} else if path.starts_with("/assets/") {
"public, max-age=31536000, immutable"
} else {
"public, max-age=3600"
}
}
#[cfg(test)]
mod tests {
use super::cache_policy;
#[test]
fn cache_rules() {
assert_eq!(cache_policy("/", true), "no-cache");
assert_eq!(
cache_policy("/defter", true),
"no-cache",
"SPA → index.html"
);
assert_eq!(cache_policy("/sw.js", false), "no-cache");
assert_eq!(cache_policy("/manifest.webmanifest", false), "no-cache");
assert_eq!(
cache_policy("/assets/index-AbC123.js", false),
"public, max-age=31536000, immutable"
);
assert_eq!(cache_policy("/icon-192.png", false), "public, max-age=3600");
assert_eq!(
cache_policy("/workbox-2ff6bd68.js", false),
"public, max-age=3600"
);
}
}
+16
View File
@@ -171,3 +171,19 @@ systemctl --user enable --now kakeibo-backup-pull.timer
- MongoDB'de kimlik doğrulaması zorunlu olmadığından (§4) `mongodump` konteynerin içinden şifresiz çalışır.
Auth açıldığında yedek için ayrı bir `backup` rolü kullanıcısı ve betiğe `--config` ile şifre dosyası eklenmeli.
- Yedek boyutu şu an birkaç KB–MB; 14 + 60 dosya önemsiz yer tutar.
## 7. Telefona kurulum (PWA) ve güncellemeler
Uygulama **HTTPS** üzerinden açıldığında telefona "uygulama gibi" kurulabilir (service worker yalnız HTTPS'te ve `localhost`'ta çalışır).
- **Android (Chrome):** siteyi aç → menü ⋮ → **Ana ekrana ekle / Uygulamayı yükle**.
- **iPhone (Safari):** siteyi aç → Paylaş ⬆ → **Ana Ekrana Ekle**.
- Ana ekran ikonuna uzun basınca kısayollar: **Harcama ekle**, **Panel**.
Nasıl çalışır:
- Telefonda yalnızca **uygulama kabuğu** (JS, CSS, ikonlar, fontlar) saklanır → ikinci açılış çok hızlı.
- **Veri (`/api`) asla saklanmaz**: ekranda her zaman sunucudaki güncel veri vardır; bağlantı yoksa "Bağlantı yok" uyarısı çıkar, kayıt girilemez (çevrimdışı kayıt Faz 11'de, Tauri ile).
- Yeni sürüm yayınlanınca açık uygulamada **"Yeni sürüm hazır → Yenile"** bildirimi çıkar (saatte bir de kendisi yoklar). Yenile'ye basmadan eski sürüm çalışmaya devam eder; girilen kayıtlar etkilenmez.
- Sunucu: `sw.js` ve `manifest.webmanifest` hiç önbelleğe alınmaz (`routes/spa.rs`), böylece güncelleme hemen fark edilir.
Sorun olursa (eski sürüm takılı kaldı vb.): tarayıcı ayarlarından site verilerini temizlemek service worker'ı da kaldırır.
+2 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
**Faz 10 – Adım 5:** PWA — `vite-plugin-pwa` (gerekçe: manifest + Workbox service worker üretimi; CLAUDE.md yığınında var): manifest (ad, kısa ad, tema rengi, `display: standalone`, `start_url: /`, ikonlar `public/icon-192/512/maskable` zaten var), service worker yalnız uygulama kabuğu + hash'li `/assets/*` (precache), **`/api` asla önbelleğe alınmaz** (NetworkOnly), SPA gezinme `index.html` (navigateFallback, `/api` hariç), güncelleme bildirimi (`registerType: 'prompt'` → sonner "Yeni sürüm var — Yenile"), çevrimdışıyken anlaşılır mesaj. Axum: `sw.js` ve `manifest.webmanifest` `no-cache`. iOS: apple-touch-icon zaten var. Önce kullanıcıya kısa özet.
**Faz 10 ✅ tamamlandı.** Sıradaki: kullanıcının **geniş kapsamlı testi** (canlıda, telefonda). Kullanıcının bulduğu hatalar/istekler önce. Faz 11 (Tauri: Windows, Linux, Android) **yalnızca kullanıcı istediğinde** başlar. Kullanıcıya hatırlat: canlı sunucu yedeği (DEPLOY §6) gerçek veriden önce; ilk iş JSON yedeği indirmek.
**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
@@ -152,3 +152,4 @@
- **Faz 10 – Adım 2 (2026-11-13):** Backend: `PATCH /auth/me` (ad), `POST /auth/me/password` (mevcut şifre + limiter `password:<uid>`, `sessions_valid_from` → diğer oturumlar 401, bu cihaza yeni çerez, "beni hatırla" eski çerezin `exp-iat`'ından), `PATCH /household` (sahip: ad, `budget_split_pct` toplam 100, `budget_suggestion`), `DELETE /household/members/{id}` (sahip; kendini/sahibi çıkaramaz; e-posta `#removed-<id>` → yeniden davet edilebilir). `GET /household` artık `settings` döner. `services/account.rs`. Testler `tests/account.rs` (4) + 2 bilinçli bozma yakalandı. Frontend: `/ayarlar/hane` (ad, üyeler + çıkar onaylı, davet oluştur → panoya kopyala / paylaş / iptal), `/ayarlar/hesap` (ad, e-posta salt okunur, şifre formu: zod tekrar + farklı, sunucu hatası alana), `/ayarlar/butce` (4 yüzde canlı toplam, 50/30/10/10 düğmesi, yöntem radyo; üye salt okunur). Öneri seçici ve sihirbaz hanenin yöntemiyle açılır (veri yoksa yüzdelere düşer). E2E 28 kontrol: **davet bağlantısıyla gerçek kayıt** dahil (`REGISTRATION=invite_only`). Not: başsız Chrome'da pano için `Emulation.setFocusEmulationEnabled`. Backend 205, frontend 140 test.
- **Faz 10 – Adım 3 (2026-11-13):** Tarama betiği (scratchpad `e2e/scan.mjs`): 21 sayfa × telefon açık/koyu + masaüstü (+ boş hane, giriş/kayıt); sayfa içi denetim (adsız denetim, <44 px hedef, canvas ile oklch→sRGB kontrast, tek h1, alt, lang, taşma) + Tab ile odak halkası. İlk sonuç 89 kontrast / 75 küçük hedef / 27 adsız / 3 h1 → benzersiz: muted yazı 4,35 (→ token 0.52), yeşil 4,37 (→ #166534), `sm` düğmeler 36 px (→ pointer-coarse 44), GFM görev kutusu adsız (→ "Tamamlandı/Yapılacak"), rapor 2 h1 (→ `shiftHeadings`). **Ekran görüntüsünden:** telefonda Defter formu taşıyordu (ızgara öğesi `min-width:auto` 425 px) — otomatik kontrol kaçırdı çünkü mobil öykünmede `innerWidth` da büyür → kontrol `screen.width`/390 ile; Raporlar grafik kartları da taşıyordu → `Section` + rapor bölümüne `min-w-0`. Eklenen: %80 bütçe uyarısı (`lib/budget-level.ts`), Raporlar boş durumu (dönem yok → Dönemi başlat / Ekstreden aktar; filtre boş → Filtreleri temizle), tutarlarda bölünmez boşluk. Sonra: kontrast/adsız/h1/telefon hedef 0. Frontend 142 test.
- **Faz 10 – Adım 4 (2026-11-13):** Önbellek başlıkları zaten doğruydu (Faz 5: `/assets/*` 1 yıl immutable, HTML no-cache, sıkıştırma). Ana JS 950 KB (gz 284) tek paket → sayfa bazlı `lazy` route: ana 402 KB (gz 128), sayfalar 2–23 KB; Markdown (49 KB gz) ve zod/form (40 KB gz) yalnız kullanan sayfada. İlk sürümde Defter yavaşlamıştı (lazy route → `/auth/me` sıralı) → `prefetchMe` ile paralel; `HydrateFallback: Splash`. Boşta alt menü sayfalarını önden indirme + `useNavigation` ilerleme çizgisi. Ölçüm (scratchpad `e2e/perf.mjs`: yavaş 4G 1,6 Mbps/150 ms + CPU 4×, soğuk; eski sürüm `git worktree` ile ayrı derlenip aynı koşulda, 2 tekrar ort.): Giriş 2,97→2,23 sn, Panel 2,42→2,03, Raporlar 2,36→2,11, Defter 2,33→2,37 (aynı); hazırda JS 295→171–221 KB. Regresyon: katalog (25) + rapor (21) e2e geçti. Not: e2e rapor başlığı artık `.md-h1`.
- **Faz 10 – Adım 5 (2026-11-13) → Faz 10 ✅:** `vite-plugin-pwa` 2.0 + `workbox-window` (dev): manifest (tr, standalone, ikonlar + maskable, kısayollar Harcama ekle/Panel), Workbox precache 98–101 dosya (xlsx ve vietnamca/yunanca/kiril font alt kümeleri hariç → runtime CacheFirst `/assets/`), `navigateFallback` index.html (`/api` hariç). `registerType: 'prompt'` → `PwaUpdater` toast + saatlik `reg.update()`. `OfflineBanner` (useSyncExternalStore). Backend `spa.rs` `cache_policy` (sw.js, manifest, registerSW → no-cache) + birim testi. **Bulunan hata:** çevrimdışı açılışta `/auth/me` ağ hatası → RequireAuth kullanıcıyı giriş sayfasına atıyordu; ayrıca TanStack onlineManager açılış durumunu bilmediği için birkaç sn boş açılış → `SessionUnknown` ("Oturum bilgisi alınamadı… Tekrar dene") + `onlineManager.setOnline(navigator.onLine)`; bağlantı gelince kendiliğinden toparlanır. E2E 14 kontrol (CDP `Page.getInstallabilityErrors` boş, önbellekte /api 0, çevrimdışı, güncelleme: dist kopyası + sw.js index revizyonu değiştirilip sunucu yeniden başlatıldı → bildirim → Yenile → yeni sürüm, oturum/sayfa korunur). npm audit: yeni `glob` (orta) yalnız derleme zamanı; diğer uyarılar mevcut `shadcn` CLI'dan. Docker web aşaması geçti. DEPLOY §7 kurulum.
+2 -2
View File
@@ -14,7 +14,7 @@
| **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 | ✅ |
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | 🔄 |
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ✅ |
| **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) — **kullanıcının geniş kapsamlı testinden sonra, kullanıcı istediğinde** | Masaüstü ve mobil uygulamalar | ⏸️ |
## Faz 0 Adımları
@@ -91,4 +91,4 @@
- ✅ 2. Hane ve hesap: hane adı, üyeler, **davet bağlantısı** (oluştur/kopyala/iptal), üye çıkarma; profil adı, **şifre değiştirme**; varsayılan bütçe yüzdeleri + öneri yöntemi
- ✅ 3. Arayüz cilası ve erişilebilirlik: tüm sayfaların telefon/masaüstü/koyu tema taraması, boş durumlar, skeleton, %80 bütçe uyarısı, odak/klavye, kontrast, onay diyalogları
- ✅ 4. Performans: sayfa bazlı kod bölme (lazy route), statik dosyalarda uzun önbellek, paket boyutu
- 🔄 5. PWA: manifest + service worker (uygulama kabuğu; `/api` asla önbelleğe alınmaz), kurulum, güncelleme bildirimi
- ✅ 5. PWA: manifest + service worker (uygulama kabuğu; `/api` asla önbelleğe alınmaz), kurulum, güncelleme bildirimi
+4443 -1
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -45,6 +45,8 @@
"prettier": "^3.10.0",
"typescript": "~7.0.2",
"vite": "^8.3.6",
"vitest": "^5.1.0"
"vite-plugin-pwa": "^2.0.0",
"vitest": "^5.1.0",
"workbox-window": "^7.4.1"
}
}
@@ -1,4 +1,6 @@
import { RotateCw, WifiOff } from 'lucide-react'
import { Navigate, Outlet, useLocation, useSearchParams } from 'react-router'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/logo'
import { useMe } from '@/lib/auth'
import { safeReturnPath } from '@/lib/return-path'
@@ -14,12 +16,37 @@ export function Splash() {
)
}
/**
* Oturum sorulamadı (bağlantı yok / sunucu yanıt vermiyor): giriş sayfasına ATMA —
* kullanıcı çıkış yapılmış sanır. Bağlantı gelince sorgu kendiliğinden yenilenir.
*/
function SessionUnknown({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="grid min-h-dvh place-items-center p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center" role="alert">
<Logo size={56} />
<WifiOff className="size-6 text-muted-foreground" aria-hidden />
<p className="text-muted-foreground">{message}</p>
<Button onClick={onRetry}>
<RotateCw aria-hidden />
{t.common.retry}
</Button>
</div>
</div>
)
}
/** Oturum gerektiren sayfalar. Oturum yoksa → /giris?donus=<şimdiki adres> */
export function RequireAuth() {
const me = useMe()
const location = useLocation()
// Çevrimdışıyken sorgu duraklar (hata vermez) → sonsuz açılış ekranı yerine bağlantı mesajı
if (me.isPending && me.fetchStatus === 'paused')
return <SessionUnknown message={t.pwa.sessionOffline} onRetry={() => void me.refetch()} />
if (me.isPending) return <Splash />
if (me.isError)
return <SessionUnknown message={me.error.message} onRetry={() => void me.refetch()} />
if (!me.data) {
const back = location.pathname + location.search
const to = back === '/' ? paths.login : `${paths.login}?donus=${encodeURIComponent(back)}`
@@ -36,7 +63,12 @@ export function RequireAuth() {
export function RequireGuest() {
const me = useMe()
const [params] = useSearchParams()
// Çevrimdışıyken sorgu duraklar (hata vermez) → sonsuz açılış ekranı yerine bağlantı mesajı
if (me.isPending && me.fetchStatus === 'paused')
return <SessionUnknown message={t.pwa.sessionOffline} onRetry={() => void me.refetch()} />
if (me.isPending) return <Splash />
if (me.isError)
return <SessionUnknown message={me.error.message} onRetry={() => void me.refetch()} />
if (me.data) return <Navigate to={safeReturnPath(params.get('donus'), paths.dashboard)} replace />
return <Outlet />
}
+19
View File
@@ -0,0 +1,19 @@
/** Tarayıcı çevrimiçi mi? (`online` / `offline` olaylarıyla güncellenir) */
import { useSyncExternalStore } from 'react'
function subscribe(cb: () => void) {
window.addEventListener('online', cb)
window.addEventListener('offline', cb)
return () => {
window.removeEventListener('online', cb)
window.removeEventListener('offline', cb)
}
}
export function useOnline(): boolean {
return useSyncExternalStore(
subscribe,
() => navigator.onLine,
() => true,
)
}
+53
View File
@@ -0,0 +1,53 @@
/**
* PWA: yeni sürüm bildirimi ve çevrimdışı uyarısı.
* Service worker yalnızca uygulama kabuğunu saklar; veri (/api) her zaman sunucudan gelir.
*/
import { useEffect } from 'react'
import { WifiOff } from 'lucide-react'
import { toast } from 'sonner'
import { useRegisterSW } from 'virtual:pwa-register/react'
import { useOnline } from './online'
import { t } from './strings'
const s = t.pwa
/** Uygulama uzun süre açık kalırsa (telefonda sık) yeni sürüm saatte bir yoklanır */
const CHECK_EVERY = 60 * 60 * 1000
/** Yeni sürüm hazır olunca kalıcı bildirim: "Yenile" → yeni service worker devreye girer */
export function PwaUpdater() {
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegisteredSW(_url, reg) {
if (reg) setInterval(() => void reg.update().catch(() => undefined), CHECK_EVERY)
},
})
useEffect(() => {
if (!needRefresh) return
const id = toast(s.updateTitle, {
description: s.updateText,
duration: Infinity,
action: { label: s.reload, onClick: () => void updateServiceWorker(true) },
})
return () => void toast.dismiss(id)
}, [needRefresh, updateServiceWorker])
return null
}
/** Bağlantı yokken ekranın üstünde sabit uyarı (kayıtlar sunucuya yazıldığı için girilemez) */
export function OfflineBanner() {
const online = useOnline()
if (online) return null
return (
<div
role="status"
className="fixed inset-x-0 top-0 z-50 flex items-center justify-center gap-2 bg-warning px-4 py-2 text-sm font-medium text-white shadow print:hidden"
>
<WifiOff className="size-4 shrink-0" aria-hidden />
{s.offline}
</div>
)
}
+8
View File
@@ -939,6 +939,14 @@ export const t = {
savingsUpdated: 'Birikimlerdeki kayıt yeni tutarla güncellendi.',
},
pwa: {
updateTitle: 'Yeni sürüm hazır',
updateText: 'Değişiklikleri görmek için yenile. Girdiğin kayıtlar kaybolmaz.',
reload: 'Yenile',
sessionOffline: 'Oturum bilgisi alınamadı. Bağlantı gelince kendiliğinden devam eder.',
offline:
'Bağlantı yok — veriler gösterilemez ve kayıt girilemez. Bağlantı gelince devam edebilirsin.',
},
common: {
loading: 'Yükleniyor…',
retry: 'Tekrar dene',
+7 -1
View File
@@ -1,16 +1,20 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { onlineManager, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from 'react-router'
import './index.css'
import { queryClient } from '@/lib/query'
import { Toaster } from '@/components/toaster'
import { prefetchMe } from '@/lib/auth'
import { OfflineBanner, PwaUpdater } from '@/lib/pwa'
import { installPrintTheme } from '@/lib/print'
import { ThemeProvider } from '@/lib/theme'
import { router } from './router'
installPrintTheme()
// TanStack Query yalnız sonraki online/offline olaylarını dinler; açılıştaki durumu da bilsin
// (çevrimdışı açılışta sorgular hemen duraklar → bekleme yerine bağlantı mesajı)
onlineManager.setOnline(navigator.onLine)
prefetchMe(queryClient)
createRoot(document.getElementById('root')!).render(
@@ -19,6 +23,8 @@ createRoot(document.getElementById('root')!).render(
<ThemeProvider>
<RouterProvider router={router} />
<Toaster />
<PwaUpdater />
<OfflineBanner />
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
+1 -1
View File
@@ -4,7 +4,7 @@
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"types": ["vite/client", "vite-plugin-pwa/react"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
+69 -1
View File
@@ -2,10 +2,78 @@ import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
// PWA: telefona "Ana ekrana ekle" ile kurulum + uygulama kabuğunun önbelleği.
// Veri (/api) ASLA önbelleğe alınmaz: ekranda her zaman sunucudaki güncel veri olur.
VitePWA({
// Yeni sürüm kendiliğinden devreye girmez; kullanıcıya "Yenile" sorulur (lib/pwa.tsx)
registerType: 'prompt',
injectRegister: false,
manifest: {
name: 'Kakeibo — Ev Defteri',
short_name: 'Kakeibo',
description: 'Kakeibo yöntemiyle hane bazlı ev bütçesi ve harcama defteri',
lang: 'tr',
dir: 'ltr',
start_url: '/',
scope: '/',
display: 'standalone',
orientation: 'portrait',
background_color: '#ffffff',
theme_color: '#ffffff',
categories: ['finance', 'productivity'],
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{
src: '/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
// Ana ekran ikonuna uzun basınca kısayollar
shortcuts: [
{
name: 'Harcama ekle',
short_name: 'Harcama',
url: '/defter',
icons: [{ src: '/icon-192.png', sizes: '192x192' }],
},
{ name: 'Panel', url: '/', icons: [{ src: '/icon-192.png', sizes: '192x192' }] },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
// Nadiren gerekenler önceden indirilmez (ilk kurulum küçük kalsın): Excel okuyucu,
// Türkçede kullanılmayan font alt kümeleri. Gerekirse açılınca önbelleğe girer (aşağıda).
globIgnores: [
'**/xlsx-*.js',
'**/inter-{vietnamese,greek,greek-ext,cyrillic,cyrillic-ext}-*',
],
// Tek sayfa uygulama: gezinmeler index.html'e; /api asla
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api\//],
cleanupOutdatedCaches: true,
runtimeCaching: [
{
// Hash'li dosya adı → içerik değişmez; önceden alınmamışsa ilk kullanımda saklanır
urlPattern: ({ url }) => url.pathname.startsWith('/assets/'),
handler: 'CacheFirst',
options: { cacheName: 'kakeibo-assets', expiration: { maxEntries: 60 } },
},
],
},
// Geliştirmede service worker yok (hot reload ile çakışmasın)
devOptions: { enabled: false },
}),
],
resolve: {
// "@/lib/format" → src/lib/format
alias: { '@': path.resolve(import.meta.dirname, './src') },