diff --git a/src/components/tools/PwaSettings.jsx b/src/components/tools/PwaSettings.jsx index 3e9ab6b..027fd4f 100644 --- a/src/components/tools/PwaSettings.jsx +++ b/src/components/tools/PwaSettings.jsx @@ -11,7 +11,7 @@ export const PwaSettings = () => { return ; }; const PwaPanel = () => { - const { userProfile, authUser, tenders, reminderSettings, saveReminderSettings, reminderSaving, reminderError, requestPushPermission, notify, isQuietNow } = useApp(); + const { userProfile, authUser, tenders, reminderSettings, saveReminderSettings, reminderSaving, reminderError, requestPushPermission, notify, isQuietNow, offlineQueueSize } = useApp(); // Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine const [onbellekSayisi, setOnbellekSayisi] = useState(null); const [onbellekAdi, setOnbellekAdi] = useState(''); @@ -156,6 +156,10 @@ const PwaPanel = () => { Önbelleğe alınmış istek: {onbellekSayisi == null ? '—' : `${onbellekSayisi} kayıt`}

+

+ Gönderim bekleyen çevrimdışı kayıt: + {offlineQueueSize} +

Uygulama sürümü önbelleği: {onbellekAdi || '—'} @@ -169,7 +173,7 @@ const PwaPanel = () => { {cacheLoading ? 'Kontrol ediliyor…' : 'Önbellek durumunu kontrol et'} {cacheError &&

{cacheError}

} -

Bağlantı durumu sunucuya erişimi garanti etmez. Çevrimdışı değişiklikler otomatik gönderim kuyruğuna alınmaz; kayıt sonucunu kontrol edin.

+

Bağlantı kesildiğinde ihale hazırlık değişiklikleri hesap kapsamlı kuyruğa alınır ve bağlantı gelince yeniden gönderilir. Belge yükleme ve dış sağlayıcı işlemleri çevrimdışı başlatılamaz.

{/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */} diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index f9860b4..1555a5f 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -15,6 +15,7 @@ import { applyTenderStates, tenderStateKey, tenderSnapshot, publicTender, tender import { getTenderStates, saveTenderState } from '../services/account'; import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants'; import { istanbulDateKey } from '../services/tenderCalendar'; +import { enqueueTenderMutation, offlineMutationCount, replayOfflineMutations } from '../services/offlineMutationQueue.js'; const AppContext = createContext(); @@ -211,12 +212,45 @@ export const AppProvider = ({ children }) => { const inFlight = useRef(new Set()); const [saveError, setSaveError] = useState(null); const [savingCount, setSavingCount] = useState(0); + const [offlineQueueSize, setOfflineQueueSize] = useState(0); + const replayOfflineQueue = async (owner) => { + if (!owner || (typeof navigator !== 'undefined' && !navigator.onLine)) return; + const result = await replayOfflineMutations(owner, async item => { + if (item.kind !== 'tender-state') throw new Error('Çevrimdışı kayıt türü desteklenmiyor.'); + const record = await saveTenderState(item.key, item.patch, item.snapshot, item.version); + if (getStoredUser()?.email !== owner) throw new Error('Oturum değişti; kuyruk bekletildi.'); + savedTenderStates.current = { ...savedTenderStates.current, [item.key]: record }; + setTenders(prev => applyTenderStates(prev, savedTenderStates.current)); + }); + setOfflineQueueSize(offlineMutationCount(owner)); + if (result.error) setSaveError(`Çevrimdışı kayıt gönderilemedi: ${result.error.message}`); + else if (result.sent > 0) setSaveError(null); + return result; + }; + useEffect(() => { + const owner = authUser?.email; + if (!owner || !veriSenkron) { setOfflineQueueSize(0); return undefined; } + setOfflineQueueSize(offlineMutationCount(owner)); + const reconnect = () => { void replayOfflineQueue(owner); }; + window.addEventListener('online', reconnect); + if (typeof navigator === 'undefined' || navigator.onLine) void replayOfflineQueue(owner); + return () => window.removeEventListener('online', reconnect); + }, [authUser?.email, veriSenkron]); const mutateTender = async (tenderId, patch) => { const t = tenders.find(t => t.id === tenderId); if (!t || !veriSenkron || !authUser) { setSaveError('Hesap verileri yüklenmeden kayıt yapılamaz.'); return false; } const key = tenderStateKey(t); if (inFlight.current.has(key)) { setSaveError('Bu ihale için önceki kayıt sürüyor. Tamamlandıktan sonra tekrar deneyin.'); return false; } const owner = authUser.email; + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + try { + enqueueTenderMutation(owner, { key, patch, snapshot: tenderSnapshot(t), version: savedTenderStates.current[key]?.version || 0 }); + setOfflineQueueSize(offlineMutationCount(owner)); + setTenders(prev => prev.map(row => tenderStateKey(row) === key ? { ...row, ...patch } : row)); + setSaveError('Çevrimdışı değişiklik kuyruğa alındı; bağlantı gelince sunucuya gönderilecek.'); + return true; + } catch (error) { setSaveError(error.message); return false; } + } const session = getToken(); inFlight.current.add(key); setSavingCount(n => n + 1); setSaveError(null); try { @@ -906,7 +940,7 @@ export const AppProvider = ({ children }) => { planYenile, kotaUyarisi, setKotaUyarisi, - veriSenkron, saveError, savingCount, mutateTender + veriSenkron, saveError, savingCount, mutateTender, offlineQueueSize }}> {children} diff --git a/src/services/offlineMutationQueue.js b/src/services/offlineMutationQueue.js new file mode 100644 index 0000000..90085aa --- /dev/null +++ b/src/services/offlineMutationQueue.js @@ -0,0 +1,93 @@ +const PREFIX = 'ihaleist_offline_mutations:'; +const LIMIT = 100; + +const ownerKey = owner => `${PREFIX}${encodeURIComponent(String(owner || '').trim().toLowerCase())}`; + +function readStorage(owner) { + if (typeof localStorage === 'undefined' || !String(owner || '').trim()) return []; + try { + const parsed = JSON.parse(localStorage.getItem(ownerKey(owner)) || '[]'); + return Array.isArray(parsed) ? parsed.filter(item => item && typeof item === 'object') : []; + } catch { return []; } +} + +function writeStorage(owner, rows) { + if (typeof localStorage === 'undefined' || !String(owner || '').trim()) return false; + try { + localStorage.setItem(ownerKey(owner), JSON.stringify(rows.slice(-LIMIT))); + return true; + } catch { return false; } +} + +export function offlineMutationCount(owner) { + return readStorage(owner).length; +} + +export function listOfflineMutations(owner) { + return readStorage(owner).map(item => ({ ...item })); +} + +/** + * Adds a tender-state write to the account-scoped durable queue. Consecutive + * changes for the same tender are merged so reconnecting never replays stale + * intermediate drafts. Tokens and document bytes are deliberately excluded. + */ +export function enqueueTenderMutation(owner, { key, patch, snapshot, version }) { + if (!String(owner || '').trim() || !String(key || '').trim() || !patch || typeof patch !== 'object' || Array.isArray(patch)) { + throw new Error('Çevrimdışı kayıt için ihale ve değişiklik bilgisi gerekli.'); + } + const rows = readStorage(owner); + const index = rows.findIndex(item => item.kind === 'tender-state' && item.key === key); + const previous = index >= 0 ? rows[index] : null; + const next = { + id: previous?.id || `offline-${crypto.randomUUID()}`, + kind: 'tender-state', + key, + patch: { ...(previous?.patch || {}), ...patch }, + snapshot: snapshot && typeof snapshot === 'object' ? snapshot : {}, + version: Number.isInteger(version) && version >= 0 ? version : 0, + createdAt: previous?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + attempts: previous?.attempts || 0, + }; + if (index >= 0) rows[index] = next; else rows.push(next); + if (!writeStorage(owner, rows)) throw new Error('Çevrimdışı kayıt kuyruğu yazılamadı.'); + return next; +} + +export function removeOfflineMutation(owner, id) { + const rows = readStorage(owner); + return writeStorage(owner, rows.filter(item => item.id !== id)); +} + +export function markOfflineMutationAttempt(owner, id) { + const rows = readStorage(owner); + const next = rows.map(item => item.id === id ? { ...item, attempts: (item.attempts || 0) + 1, updatedAt: new Date().toISOString() } : item); + writeStorage(owner, next); + return next.find(item => item.id === id) || null; +} + +/** Replay in order; a failed item remains queued and stops the batch. */ +export async function replayOfflineMutations(owner, send) { + const rows = listOfflineMutations(owner); + const result = { sent: 0, pending: rows.length, error: null }; + for (const item of rows) { + markOfflineMutationAttempt(owner, item.id); + try { + await send(item); + removeOfflineMutation(owner, item.id); + result.sent += 1; + result.pending -= 1; + } catch (error) { + result.error = error; + break; + } + } + return result; +} + +export function clearOfflineMutations(owner) { + if (typeof localStorage === 'undefined') return false; + try { localStorage.removeItem(ownerKey(owner)); return true; } catch { return false; } +} + diff --git a/tests/offline-mutation-queue.test.mjs b/tests/offline-mutation-queue.test.mjs new file mode 100644 index 0000000..0502c4d --- /dev/null +++ b/tests/offline-mutation-queue.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + clearOfflineMutations, + enqueueTenderMutation, + listOfflineMutations, + offlineMutationCount, + replayOfflineMutations, +} from '../src/services/offlineMutationQueue.js'; + +const storage = new Map(); +globalThis.localStorage = { + getItem: key => storage.has(key) ? storage.get(key) : null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: key => storage.delete(key), +}; + +test('offline tender mutations are account scoped and merged by tender', async () => { + clearOfflineMutations('qa@example.test'); + enqueueTenderMutation('qa@example.test', { key: '2026/1', patch: { takipte: true }, snapshot: { ikn: '2026/1' }, version: 2 }); + enqueueTenderMutation('qa@example.test', { key: '2026/1', patch: { kanbanStatus: 'teklif_hazirlaniyor' }, snapshot: { ikn: '2026/1' }, version: 2 }); + enqueueTenderMutation('other@example.test', { key: '2026/1', patch: { takipte: true }, snapshot: { ikn: '2026/1' }, version: 0 }); + assert.equal(offlineMutationCount('qa@example.test'), 1); + assert.deepEqual(listOfflineMutations('qa@example.test')[0].patch, { takipte: true, kanbanStatus: 'teklif_hazirlaniyor' }); + assert.equal(offlineMutationCount('other@example.test'), 1); + clearOfflineMutations('qa@example.test'); clearOfflineMutations('other@example.test'); +}); + +test('replay removes acknowledged rows and retains the first failed row', async () => { + clearOfflineMutations('replay@example.test'); + enqueueTenderMutation('replay@example.test', { key: 'a', patch: { takipte: true }, snapshot: {}, version: 0 }); + enqueueTenderMutation('replay@example.test', { key: 'b', patch: { takipte: true }, snapshot: {}, version: 0 }); + const sent = []; + const first = await replayOfflineMutations('replay@example.test', async item => { sent.push(item.key); }); + assert.deepEqual(sent, ['a', 'b']); + assert.equal(first.pending, 0); + enqueueTenderMutation('replay@example.test', { key: 'c', patch: { takipte: true }, snapshot: {}, version: 0 }); + enqueueTenderMutation('replay@example.test', { key: 'd', patch: { takipte: true }, snapshot: {}, version: 0 }); + const second = await replayOfflineMutations('replay@example.test', async item => { if (item.key === 'c') throw new Error('network'); }); + assert.equal(second.sent, 0); + assert.equal(listOfflineMutations('replay@example.test')[0].key, 'c'); + assert.equal(listOfflineMutations('replay@example.test')[0].attempts, 1); + clearOfflineMutations('replay@example.test'); +}); +