PWA Score Apps — Shared Blocks
Shared noweb blocks for tally_score_tracker.org and score_counter.org.
Consumer files use cross-file references like <<pwa_score_apps_shared_blocks.org:head-meta-ex()>>.
Head and PWA configuration
Meta tags
Standard PWA meta tags. The purge script at the top provides a ?purge query
parameter escape hatch: it nukes the service worker cache and registrations,
then reloads — useful when a bad SW gets stuck in production.
<meta charset="UTF-8">
<script>
if(new URLSearchParams(location.search).has('purge')&&'serviceWorker'in navigator){
caches.keys().then(function(k){k.forEach(function(n){caches.delete(n)})});
navigator.serviceWorker.getRegistrations().then(function(r){r.forEach(function(s){s.unregister()})});
location.replace(location.pathname);
}
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1a2e">
<link rel="manifest" href="manifest.json">
<title>Tally</title>
Import map
All dependencies loaded via ESM from esm.sh. The deps=yjs@13.6.29 pins ensure
y-indexeddb, y-websocket and y-protocols all share the exact same Yjs instance —
without this, each would bundle its own copy, breaking cross-module instanceof
checks.
<script type="importmap">
{"imports":{
"yjs":"https://esm.sh/yjs@13.6.29",
"y-indexeddb":"https://esm.sh/y-indexeddb@9?deps=yjs@13.6.29",
"y-websocket":"https://esm.sh/y-websocket@2?bundle-deps&deps=yjs@13.6.29",
"lib0/broadcastchannel":"https://esm.sh/lib0@0.2.99/broadcastchannel",
"lib0/time":"https://esm.sh/lib0@0.2.99/time",
"lib0/encoding":"https://esm.sh/lib0@0.2.99/encoding",
"lib0/decoding":"https://esm.sh/lib0@0.2.99/decoding",
"lib0/observable":"https://esm.sh/lib0@0.2.99/observable",
"lib0/math":"https://esm.sh/lib0@0.2.99/math",
"lib0/url":"https://esm.sh/lib0@0.2.99/url",
"lib0/environment":"https://esm.sh/lib0@0.2.99/environment",
"y-protocols/sync":"https://esm.sh/y-protocols@1.0.6/sync?deps=yjs@13.6.29",
"y-protocols/auth":"https://esm.sh/y-protocols@1.0.6/auth?deps=yjs@13.6.29",
"y-protocols/awareness":"https://esm.sh/y-protocols@1.0.6/awareness?deps=yjs@13.6.29",
"alpinejs":"https://esm.sh/alpinejs@3"
}}
</script>
Service worker
Network-first strategy for navigation (always fetch the latest HTML), cache-first for static assets (CDN libraries that are versioned in the import map). On install, pre-caches the shell and Alpine. On activate, purges stale caches.
const CACHE = 'tally-v6';
const ASSETS = [
'./',
'./index.html',
'https://esm.sh/alpinejs@3',
'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js'
];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', e => {
if (e.request.mode === 'navigate') {
// Network-first for pages — always get the latest HTML
e.respondWith(
fetch(e.request)
.then(r => caches.open(CACHE).then(c => { c.put(e.request, r.clone()); return r; }))
.catch(() => caches.match(e.request))
);
} else {
// Cache-first for static assets (CDN libs)
e.respondWith(
caches.match(e.request).then(r => r || fetch(e.request))
);
}
});
Manifest
{
"name": "Tally",
"short_name": "Tally",
"description": "Score tracker for board games",
"start_url": ".",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e",
"icons": [
{
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='96' fill='%231a1a2e'/><text x='256' y='340' font-size='280' text-anchor='middle' fill='%23e94560'>T</text></svg>",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
Sync infrastructure
Shared WebSocket connection, remote-change rendering, and init logic. Each app
calls setupSync(persistence, ydoc, onReady) where onReady is the
app-specific first-render function (e.g. render() or Alpine.store('app').syncFromYjs()).
The scheduleRender function is set by the caller so that remote Yjs updates
and IndexedDB sync both trigger the app’s render pipeline.
// ── Shared sync infrastructure ──
var wsProvider;
var _rendering = false;
var _scheduleRender;
function setupSync(persistence, ydoc, onReady, scheduleRenderFn) {
_scheduleRender = scheduleRenderFn || function() {
if (_rendering) return;
_rendering = true;
requestAnimationFrame(function() { _rendering = false; onReady(); });
};
// Re-render on any non-local Yjs update (including IndexedDB sync)
ydoc.on('update', function(update, origin) {
if (origin === null) return;
_scheduleRender();
});
// WebSocket — connect only after IndexedDB synced, retry on denial
var _wsRetryTimer = null;
function connectWs() {
try {
if (wsProvider) return;
var wsHost = location.host.endsWith('.konubinix.eu') ? 'konubinix.eu' : location.host;
var wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + wsHost + '/ywebsocket';
var wsEverConnected = false;
wsProvider = new WebsocketProvider(wsUrl, _roomName, ydoc);
var syncBtn = document.getElementById('syncDot');
wsProvider.on('status', function(e) {
if (e.status === 'connected') {
wsEverConnected = true;
if (_wsRetryTimer) { clearInterval(_wsRetryTimer); _wsRetryTimer = null; }
}
var state = e.status === 'connected' ? 'connected' : 'offline';
document.body.setAttribute('data-ws', state);
if (syncBtn) syncBtn.title = state === 'connected' ? 'Synced' : 'Offline';
});
wsProvider.on('connection-close', function(e) {
if (!wsEverConnected) {
document.body.setAttribute('data-ws', 'denied');
if (syncBtn) syncBtn.title = 'Not authorized';
wsProvider.destroy();
wsProvider = null;
if (!_wsRetryTimer) {
_wsRetryTimer = setInterval(connectWs, 10000);
}
}
});
} catch(e) { console.warn('WebSocket setup failed:', e); }
}
function init() {
onReady();
document.body.setAttribute('data-ready', 'true');
}
persistence.once('synced', connectWs);
persistence.once('synced', function() { setTimeout(init, 0); });
if (persistence.synced) { connectWs(); setTimeout(init, 0); }
if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' });
}