mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 02:22:17 +00:00
feat(offline): open entry details from the saved snapshot
Opening an entry while offline needed a request that could not be made. Read it from the snapshot claimed by the signed-in account instead, re-checking ownership after the read so a sign-in mid-read cannot surface another account's data, and let controls marked data-offline-action stay usable in read-only mode.
This commit is contained in:
@@ -265,3 +265,27 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
|
|||||||
window.removeEventListener('online', schedule);
|
window.removeEventListener('online', schedule);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read details only from the snapshot currently claimed by this account. */
|
||||||
|
export async function readOfflineEntry(userId: string, pokedexId: string, entryId: string) {
|
||||||
|
if (typeof window === 'undefined' || !('caches' in window)) return null;
|
||||||
|
const meta = (await readOfflineMeta()) as (OfflineMeta & { dataCache?: string }) | null;
|
||||||
|
if (
|
||||||
|
meta?.userId !== userId ||
|
||||||
|
meta.format !== OFFLINE_META_FORMAT ||
|
||||||
|
!meta.dataCache?.startsWith(`${OFFLINE_CACHE_PREFIX}data-v1-${userId}-`)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const response = await (
|
||||||
|
await caches.open(meta.dataCache)
|
||||||
|
).match(`/__offline/snapshot/${encodeURIComponent(userId)}`);
|
||||||
|
const snapshot = (await response?.json()) as OfflineSnapshot | undefined;
|
||||||
|
const current = await readOfflineMeta();
|
||||||
|
if (current?.userId !== userId || snapshot?.userId !== userId || snapshot.version !== 1)
|
||||||
|
return null;
|
||||||
|
return (
|
||||||
|
snapshot.pokedexes
|
||||||
|
.find((dex) => dex.pokedex._id === pokedexId)
|
||||||
|
?.entries.find((row) => row.pokedexEntry._id === entryId) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
if (navigator.onLine) return;
|
if (navigator.onLine) return;
|
||||||
const target = event.target instanceof Element ? event.target : null;
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
if (!target?.closest('button, input, textarea, select, form')) return;
|
if (!target?.closest('button, input, textarea, select, form')) return;
|
||||||
|
if (target.closest('[data-offline-action]')) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
};
|
};
|
||||||
@@ -313,10 +314,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(.offline-readonly button),
|
:global(.offline-readonly button:not([data-offline-action])),
|
||||||
:global(.offline-readonly input),
|
:global(.offline-readonly input:not([data-offline-action])),
|
||||||
:global(.offline-readonly textarea),
|
:global(.offline-readonly textarea:not([data-offline-action])),
|
||||||
:global(.offline-readonly select) {
|
:global(.offline-readonly select:not([data-offline-action])) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
vi.mock('$env/static/public', () => ({ PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'true' }));
|
||||||
|
import { readOfflineEntry } from '$lib/stores/offlineSync';
|
||||||
|
|
||||||
|
describe('offline detail snapshots', () => {
|
||||||
|
const row = {
|
||||||
|
pokedexEntry: { _id: '1', catchInformation: 'Full instructions' },
|
||||||
|
catchRecord: { personalNotes: 'Saved note' }
|
||||||
|
};
|
||||||
|
function snapshot(owner = 'owner', ageMs = 0, includeEntry = true) {
|
||||||
|
const meta = {
|
||||||
|
userId: owner,
|
||||||
|
format: 2,
|
||||||
|
dataCache: `livingdex-offline-data-v1-${owner}-copy`,
|
||||||
|
generatedAt: new Date(Date.now() - ageMs).toISOString()
|
||||||
|
};
|
||||||
|
const cache = {
|
||||||
|
keys: async () => ['livingdex-offline-meta-v1', meta.dataCache],
|
||||||
|
open: async (name: string) => ({
|
||||||
|
match: async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify(
|
||||||
|
name === 'livingdex-offline-meta-v1'
|
||||||
|
? meta
|
||||||
|
: {
|
||||||
|
userId: owner,
|
||||||
|
version: 1,
|
||||||
|
pokedexes: [{ pokedex: { _id: 'dex' }, entries: includeEntry ? [row] : [] }]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
vi.stubGlobal('window', { caches: cache });
|
||||||
|
vi.stubGlobal('caches', cache);
|
||||||
|
}
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
it.each([0, 60 * 60 * 1000])(
|
||||||
|
'uses a matching full snapshot offline even when %i ms old',
|
||||||
|
async (age) => {
|
||||||
|
snapshot('owner', age);
|
||||||
|
expect(await readOfflineEntry('owner', 'dex', '1')).toEqual(row);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
it('never reads another account snapshot', async () => {
|
||||||
|
snapshot('other');
|
||||||
|
expect(await readOfflineEntry('owner', 'dex', '1')).toBeNull();
|
||||||
|
});
|
||||||
|
it('reports missing entries and dexes without fabricating details', async () => {
|
||||||
|
snapshot('owner', 0, false);
|
||||||
|
expect(await readOfflineEntry('owner', 'dex', '1')).toBeNull();
|
||||||
|
expect(await readOfflineEntry('owner', 'missing', '1')).toBeNull();
|
||||||
|
});
|
||||||
|
it('works without Cache Storage', async () => {
|
||||||
|
vi.stubGlobal('window', {});
|
||||||
|
expect(await readOfflineEntry('owner', 'dex', '1')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user