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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+4443
-1
File diff suppressed because it is too large
Load Diff
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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') },
|
||||
|
||||
Reference in New Issue
Block a user