Implements offline-first calendar sync infrastructure

Adds IndexedDB and operation queue for robust offline synchronization
Introduces SyncManager to handle background data synchronization
Supports local event operations with automatic remote sync queuing

Enhances application reliability and user experience in low/no connectivity scenarios
This commit is contained in:
Janus C. H. Knudsen 2025-11-05 00:37:57 +01:00
parent 9c765b35ab
commit e7011526e3
20 changed files with 3822 additions and 57 deletions

View file

@ -0,0 +1,854 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SYNC QUEUE VISUALIZATION | Calendar System</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-primary: #f8f9fa;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f3f5;
--border-color: #dee2e6;
--text-primary: #212529;
--text-secondary: #495057;
--text-muted: #6c757d;
--accent-primary: #0066cc;
--accent-secondary: #6610f2;
--success: #28a745;
--warning: #ffc107;
--error: #dc3545;
--info: #17a2b8;
}
body {
font-family: 'JetBrains Mono', 'Courier New', monospace;
background: var(--bg-primary);
color: var(--text-primary);
padding: 20px;
line-height: 1.6;
font-size: 13px;
}
.header {
background: var(--bg-secondary);
padding: 24px;
border: 2px solid var(--border-color);
margin-bottom: 20px;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
}
h1 {
color: var(--text-primary);
margin-bottom: 6px;
font-size: 18px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
h1::before {
content: '▶ ';
color: var(--accent-primary);
}
.subtitle {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 20px;
font-weight: 400;
}
.status-bar {
display: flex;
gap: 16px;
flex-wrap: wrap;
padding: 12px 0;
border-top: 2px solid var(--border-color);
border-bottom: 2px solid var(--border-color);
margin: 16px 0;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-label {
color: var(--text-muted);
}
.status-badge {
padding: 4px 12px;
border-radius: 2px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.8px;
border: 1px solid;
}
.status-online {
background: var(--success);
color: white;
border-color: var(--success);
}
.status-offline {
background: var(--error);
color: white;
border-color: var(--error);
}
.status-syncing {
background: var(--warning);
color: #212529;
border-color: var(--warning);
}
.status-idle {
background: var(--text-muted);
color: white;
border-color: var(--text-muted);
}
.controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
padding: 8px 14px;
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: all 0.15s ease;
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.5px;
}
button:hover {
border-color: var(--accent-primary);
background: var(--bg-tertiary);
color: var(--accent-primary);
}
button:active {
transform: scale(0.98);
}
.btn-primary {
border-color: var(--info);
color: var(--info);
}
.btn-success {
border-color: var(--success);
color: var(--success);
}
.btn-danger {
border-color: var(--error);
color: var(--error);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.panel {
background: var(--bg-secondary);
border: 2px solid var(--border-color);
position: relative;
}
.panel::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-primary), transparent);
}
.panel-title {
font-size: 12px;
font-weight: 600;
color: var(--accent-primary);
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid var(--border-color);
text-transform: uppercase;
letter-spacing: 1px;
background: var(--bg-tertiary);
}
.count-badge {
background: var(--bg-secondary);
color: var(--text-primary);
padding: 4px 10px;
border: 2px solid var(--border-color);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
}
.event-list {
max-height: 400px;
overflow-y: auto;
padding: 12px;
}
.event-list::-webkit-scrollbar {
width: 10px;
}
.event-list::-webkit-scrollbar-track {
background: var(--bg-tertiary);
}
.event-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border: 2px solid var(--bg-tertiary);
}
.event-list::-webkit-scrollbar-thumb:hover {
background: var(--accent-primary);
}
.event-item {
padding: 12px;
border: 2px solid var(--border-color);
margin-bottom: 8px;
transition: all 0.15s ease;
background: var(--bg-tertiary);
}
.event-item:hover {
border-color: var(--accent-primary);
transform: translateX(2px);
box-shadow: 2px 0 0 var(--accent-primary);
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.event-title {
font-weight: 600;
color: var(--text-primary);
font-size: 12px;
}
.sync-status {
padding: 3px 10px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
border: 2px solid;
}
.sync-synced {
background: #d4edda;
color: var(--success);
border-color: var(--success);
}
.sync-pending {
background: #fff3cd;
color: #856404;
border-color: var(--warning);
}
.sync-error {
background: #f8d7da;
color: var(--error);
border-color: var(--error);
}
.event-details {
font-size: 11px;
color: var(--text-muted);
line-height: 1.6;
}
.queue-item {
padding: 12px;
border-left: 3px solid var(--accent-primary);
background: var(--bg-tertiary);
margin-bottom: 8px;
border: 2px solid var(--border-color);
border-left: 3px solid var(--accent-primary);
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.operation-type {
padding: 3px 10px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
border: 2px solid;
}
.op-create {
background: #d4edda;
color: var(--success);
border-color: var(--success);
}
.op-update {
background: #d1ecf1;
color: var(--info);
border-color: var(--info);
}
.op-delete {
background: #f8d7da;
color: var(--error);
border-color: var(--error);
}
.retry-count {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
}
.log-panel {
grid-column: 1 / -1;
}
.log-list {
max-height: 300px;
overflow-y: auto;
background: var(--bg-tertiary);
padding: 16px;
font-size: 11px;
border: 2px solid var(--border-color);
}
.log-entry {
margin-bottom: 4px;
padding: 8px;
border-left: 3px solid transparent;
padding-left: 12px;
background: var(--bg-secondary);
}
.log-entry:hover {
background: var(--bg-primary);
}
.log-timestamp {
color: var(--text-muted);
margin-right: 12px;
font-weight: 600;
}
.log-info { border-left-color: var(--info); }
.log-success { border-left-color: var(--success); }
.log-warning { border-left-color: var(--warning); }
.log-error { border-left-color: var(--error); }
.log-info .log-message { color: var(--info); }
.log-success .log-message { color: var(--success); }
.log-warning .log-message { color: #856404; }
.log-error .log-message { color: var(--error); }
.empty-state {
text-align: center;
padding: 40px;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
padding: 16px;
}
.stat-item {
text-align: center;
padding: 16px;
background: var(--bg-tertiary);
border: 2px solid var(--border-color);
position: relative;
}
.stat-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--accent-primary);
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: var(--accent-primary);
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.refresh-indicator {
display: inline-block;
width: 8px;
height: 8px;
background: var(--success);
border-radius: 50%;
margin-left: 8px;
animation: pulse 2s infinite;
box-shadow: 0 0 8px var(--success);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
</style>
</head>
<body>
<div class="header">
<h1>SYNC QUEUE VISUALIZATION</h1>
<p class="subtitle">// Live monitoring of offline-first calendar sync operations</p>
<div id="initStatus" style="background: var(--warning); color: #000; padding: 12px; margin-bottom: 16px; border: 2px solid var(--border-color); text-align: center; font-weight: 600; letter-spacing: 0.5px;">
[⏳] INITIALIZING CALENDAR SYSTEM...
</div>
<div class="status-bar">
<div class="status-item">
<span class="status-label">NETWORK:</span>
<span id="networkStatus" class="status-badge status-online">ONLINE</span>
</div>
<div class="status-item">
<span class="status-label">SYNC:</span>
<span id="syncStatus" class="status-badge status-idle">IDLE</span>
</div>
<div class="status-item">
<span class="status-label">AUTO-REFRESH:</span>
<span class="refresh-indicator"></span>
</div>
<div class="status-item">
<span class="status-label">LAST SYNC:</span>
<span id="lastSyncTime" class="status-badge status-idle">NEVER</span>
</div>
</div>
<div class="controls">
<button class="btn-primary" onclick="manualSync()">TRIGGER SYNC</button>
<button class="btn-success" onclick="refreshData()">REFRESH DATA</button>
<button onclick="toggleNetworkSimulator()">TOGGLE NETWORK</button>
<button class="btn-danger" onclick="clearQueue()">CLEAR QUEUE</button>
<button class="btn-danger" onclick="clearDatabase()">CLEAR DATABASE</button>
</div>
</div>
<div class="grid">
<!-- IndexedDB Events -->
<div class="panel">
<div class="panel-title">
<span>INDEXEDDB EVENTS</span>
<span id="eventCount" class="count-badge">0</span>
</div>
<div id="eventList" class="event-list"></div>
</div>
<!-- Operation Queue -->
<div class="panel">
<div class="panel-title">
<span>OPERATION QUEUE</span>
<span id="queueCount" class="count-badge">0</span>
</div>
<div id="queueList" class="event-list"></div>
</div>
<!-- Statistics -->
<div class="panel">
<div class="panel-title">
<span>STATISTICS</span>
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value" id="statSynced">0</div>
<div class="stat-label">Synced</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statPending">0</div>
<div class="stat-label">Pending</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statError">0</div>
<div class="stat-label">Errors</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statQueue">0</div>
<div class="stat-label">In Queue</div>
</div>
</div>
</div>
<!-- Sync Log -->
<div class="panel log-panel">
<div class="panel-title">
<span>SYNC LOG</span>
<button onclick="clearLog()" style="font-size: 10px; padding: 4px 8px;">CLEAR</button>
</div>
<div id="logList" class="log-list"></div>
</div>
</div>
<!-- Load Test Initialization Script -->
<script src="test-init.js"></script>
<script>
let logEntries = [];
const MAX_LOG_ENTRIES = 100;
let calendarReady = false;
// Wait for calendar to initialize
function waitForCalendar() {
return new Promise((resolve, reject) => {
if (window.calendarDebug?.indexedDB) {
calendarReady = true;
const initStatus = document.getElementById('initStatus');
if (initStatus) {
initStatus.style.display = 'none';
}
resolve();
return;
}
const checkInterval = setInterval(() => {
if (window.calendarDebug?.indexedDB) {
calendarReady = true;
clearInterval(checkInterval);
const initStatus = document.getElementById('initStatus');
if (initStatus) {
initStatus.style.background = 'var(--success)';
initStatus.style.color = '#fff';
initStatus.textContent = '[✓] CALENDAR SYSTEM READY';
setTimeout(() => {
initStatus.style.display = 'none';
}, 1000);
}
resolve();
}
}, 100);
// Timeout after 10 seconds
setTimeout(() => {
if (!calendarReady) {
clearInterval(checkInterval);
const initStatus = document.getElementById('initStatus');
if (initStatus) {
initStatus.style.background = 'var(--error)';
initStatus.style.color = '#fff';
initStatus.textContent = '[✗] CALENDAR SYSTEM FAILED TO INITIALIZE - Check console for details';
}
reject(new Error('Calendar failed to initialize within 10 seconds'));
}
}, 10000);
});
}
// Initialize
async function init() {
log('info', 'Waiting for calendar system to initialize...');
try {
await waitForCalendar();
log('success', 'Connected to calendar IndexedDB');
} catch (error) {
log('error', 'Calendar system failed to initialize: ' + error.message);
return;
}
// Listen to network events
window.addEventListener('online', () => {
updateNetworkStatus(true);
log('success', 'Network online');
});
window.addEventListener('offline', () => {
updateNetworkStatus(false);
log('warning', 'Network offline');
});
// Initial load
await refreshData();
// Auto-refresh every 2 seconds
setInterval(refreshData, 2000);
}
async function refreshData() {
try {
const db = window.calendarDebug.indexedDB;
const queue = window.calendarDebug.queue;
if (!db || !queue) {
log('error', 'IndexedDB or Queue not available');
return;
}
// Get events
const events = await db.getAllEvents();
renderEvents(events);
// Get queue
const queueItems = await queue.getAll();
renderQueue(queueItems);
// Update statistics
updateStatistics(events, queueItems);
} catch (error) {
log('error', `Refresh failed: ${error.message}`);
}
}
function renderEvents(events) {
const container = document.getElementById('eventList');
document.getElementById('eventCount').textContent = events.length;
if (events.length === 0) {
container.innerHTML = '<div class="empty-state">No events in IndexedDB</div>';
return;
}
container.innerHTML = events.map(event => `
<div class="event-item">
<div class="event-header">
<span class="event-title">${event.title}</span>
<span class="sync-status sync-${event.syncStatus}">${event.syncStatus}</span>
</div>
<div class="event-details">
ID: ${event.id}<br>
${event.allDay ? 'ALL-DAY' : formatTime(event.start) + ' - ' + formatTime(event.end)}
</div>
</div>
`).join('');
}
function renderQueue(queueItems) {
const container = document.getElementById('queueList');
document.getElementById('queueCount').textContent = queueItems.length;
if (queueItems.length === 0) {
container.innerHTML = '<div class="empty-state">Queue is empty</div>';
return;
}
container.innerHTML = queueItems.map(item => `
<div class="queue-item">
<div class="queue-header">
<span class="operation-type op-${item.type}">${item.type}</span>
<span class="retry-count">RETRY: ${item.retryCount}/5</span>
</div>
<div class="event-details">
EVENT ID: ${item.eventId}<br>
TIMESTAMP: ${new Date(item.timestamp).toLocaleTimeString('da-DK')}
</div>
</div>
`).join('');
}
function updateStatistics(events, queueItems) {
const synced = events.filter(e => e.syncStatus === 'synced').length;
const pending = events.filter(e => e.syncStatus === 'pending').length;
const error = events.filter(e => e.syncStatus === 'error').length;
document.getElementById('statSynced').textContent = synced;
document.getElementById('statPending').textContent = pending;
document.getElementById('statError').textContent = error;
document.getElementById('statQueue').textContent = queueItems.length;
}
function updateNetworkStatus(isOnline) {
const badge = document.getElementById('networkStatus');
badge.textContent = isOnline ? 'ONLINE' : 'OFFLINE';
badge.className = `status-badge ${isOnline ? 'status-online' : 'status-offline'}`;
}
function updateSyncStatus(isSyncing) {
const badge = document.getElementById('syncStatus');
badge.textContent = isSyncing ? 'SYNCING' : 'IDLE';
badge.className = `status-badge ${isSyncing ? 'status-syncing' : 'status-idle'}`;
}
async function manualSync() {
const timestamp = new Date().toLocaleTimeString('da-DK');
log('info', `Manual sync triggered at ${timestamp}`);
updateSyncStatus(true);
try {
const syncManager = window.calendarDebug.syncManager;
if (syncManager) {
await syncManager.triggerManualSync();
log('success', `Manual sync completed at ${timestamp}`);
updateLastSyncTime(timestamp, 'success');
} else {
log('error', 'SyncManager not available');
updateLastSyncTime(timestamp, 'error');
}
} catch (error) {
log('error', `Manual sync failed: ${error.message}`);
updateLastSyncTime(timestamp, 'error');
} finally {
updateSyncStatus(false);
await refreshData();
}
}
function updateLastSyncTime(timestamp, status = 'success') {
const badge = document.getElementById('lastSyncTime');
badge.textContent = timestamp;
badge.className = `status-badge status-${status}`;
}
async function clearQueue() {
if (!confirm('Clear all operations from the queue?')) return;
log('warning', 'Clearing queue...');
try {
const queue = window.calendarDebug.queue;
await queue.clear();
log('success', 'Queue cleared');
await refreshData();
} catch (error) {
log('error', `Failed to clear queue: ${error.message}`);
}
}
async function clearDatabase() {
if (!confirm('⚠️ WARNING: This will delete ALL events from IndexedDB! Continue?')) return;
log('warning', 'Clearing database...');
try {
const db = window.calendarDebug.indexedDB;
db.close();
await new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase('CalendarDB');
request.onsuccess = resolve;
request.onerror = reject;
});
log('success', 'Database cleared - please reload the page');
alert('Database cleared! Please reload the page.');
} catch (error) {
log('error', `Failed to clear database: ${error.message}`);
}
}
function toggleNetworkSimulator() {
const isCurrentlyOnline = navigator.onLine;
log('info', `Network simulator toggle (currently ${isCurrentlyOnline ? 'online' : 'offline'})`);
log('warning', 'Use DevTools > Network > Offline for real offline testing');
}
function log(level, message) {
const timestamp = new Date().toLocaleTimeString('da-DK');
const entry = { timestamp, level, message };
logEntries.unshift(entry);
if (logEntries.length > MAX_LOG_ENTRIES) {
logEntries.pop();
}
renderLog();
}
function renderLog() {
const container = document.getElementById('logList');
container.innerHTML = logEntries.map(entry => `
<div class="log-entry log-${entry.level}">
<span class="log-timestamp">[${entry.timestamp}]</span>
<span class="log-message">${entry.message}</span>
</div>
`).join('');
}
function clearLog() {
logEntries = [];
renderLog();
log('info', 'Log cleared');
}
function formatTime(date) {
return new Date(date).toLocaleTimeString('da-DK', {
hour: '2-digit',
minute: '2-digit'
});
}
// Start on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
</script>
</body>
</html>