feat: add offline tender mutation queue replay
This commit is contained in:
@@ -11,7 +11,7 @@ export const PwaSettings = () => {
|
|||||||
return <PwaPanel key={`${authUser?.email}:${getToken()}`}/>;
|
return <PwaPanel key={`${authUser?.email}:${getToken()}`}/>;
|
||||||
};
|
};
|
||||||
const PwaPanel = () => {
|
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
|
// Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine
|
||||||
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
|
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
|
||||||
const [onbellekAdi, setOnbellekAdi] = useState('');
|
const [onbellekAdi, setOnbellekAdi] = useState('');
|
||||||
@@ -156,6 +156,10 @@ const PwaPanel = () => {
|
|||||||
<span>Önbelleğe alınmış istek:</span>
|
<span>Önbelleğe alınmış istek:</span>
|
||||||
<strong className="text-slate-900">{onbellekSayisi == null ? '—' : `${onbellekSayisi} kayıt`}</strong>
|
<strong className="text-slate-900">{onbellekSayisi == null ? '—' : `${onbellekSayisi} kayıt`}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
<p className="flex justify-between">
|
||||||
|
<span>Gönderim bekleyen çevrimdışı kayıt:</span>
|
||||||
|
<strong className="text-slate-900">{offlineQueueSize}</strong>
|
||||||
|
</p>
|
||||||
<p className="flex justify-between">
|
<p className="flex justify-between">
|
||||||
<span>Uygulama sürümü önbelleği:</span>
|
<span>Uygulama sürümü önbelleği:</span>
|
||||||
<strong className="text-emerald-700">{onbellekAdi || '—'}</strong>
|
<strong className="text-emerald-700">{onbellekAdi || '—'}</strong>
|
||||||
@@ -169,7 +173,7 @@ const PwaPanel = () => {
|
|||||||
{cacheLoading ? 'Kontrol ediliyor…' : 'Önbellek durumunu kontrol et'}
|
{cacheLoading ? 'Kontrol ediliyor…' : 'Önbellek durumunu kontrol et'}
|
||||||
</button>
|
</button>
|
||||||
{cacheError && <p role="alert" className="text-xs text-red-700">{cacheError}</p>}
|
{cacheError && <p role="alert" className="text-xs text-red-700">{cacheError}</p>}
|
||||||
<p className="text-xs text-slate-600">Bağlantı durumu sunucuya erişimi garanti etmez. Çevrimdışı değişiklikler otomatik gönderim kuyruğuna alınmaz; kayıt sonucunu kontrol edin.</p>
|
<p className="text-xs text-slate-600">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.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */}
|
{/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { applyTenderStates, tenderStateKey, tenderSnapshot, publicTender, tender
|
|||||||
import { getTenderStates, saveTenderState } from '../services/account';
|
import { getTenderStates, saveTenderState } from '../services/account';
|
||||||
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
||||||
import { istanbulDateKey } from '../services/tenderCalendar';
|
import { istanbulDateKey } from '../services/tenderCalendar';
|
||||||
|
import { enqueueTenderMutation, offlineMutationCount, replayOfflineMutations } from '../services/offlineMutationQueue.js';
|
||||||
|
|
||||||
const AppContext = createContext();
|
const AppContext = createContext();
|
||||||
|
|
||||||
@@ -211,12 +212,45 @@ export const AppProvider = ({ children }) => {
|
|||||||
const inFlight = useRef(new Set());
|
const inFlight = useRef(new Set());
|
||||||
const [saveError, setSaveError] = useState(null);
|
const [saveError, setSaveError] = useState(null);
|
||||||
const [savingCount, setSavingCount] = useState(0);
|
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 mutateTender = async (tenderId, patch) => {
|
||||||
const t = tenders.find(t => t.id === tenderId);
|
const t = tenders.find(t => t.id === tenderId);
|
||||||
if (!t || !veriSenkron || !authUser) { setSaveError('Hesap verileri yüklenmeden kayıt yapılamaz.'); return false; }
|
if (!t || !veriSenkron || !authUser) { setSaveError('Hesap verileri yüklenmeden kayıt yapılamaz.'); return false; }
|
||||||
const key = tenderStateKey(t);
|
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; }
|
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;
|
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();
|
const session = getToken();
|
||||||
inFlight.current.add(key); setSavingCount(n => n + 1); setSaveError(null);
|
inFlight.current.add(key); setSavingCount(n => n + 1); setSaveError(null);
|
||||||
try {
|
try {
|
||||||
@@ -906,7 +940,7 @@ export const AppProvider = ({ children }) => {
|
|||||||
planYenile,
|
planYenile,
|
||||||
kotaUyarisi,
|
kotaUyarisi,
|
||||||
setKotaUyarisi,
|
setKotaUyarisi,
|
||||||
veriSenkron, saveError, savingCount, mutateTender
|
veriSenkron, saveError, savingCount, mutateTender, offlineQueueSize
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user