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,974 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OFFLINE MODE TESTING | 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;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.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;
}
.network-status {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
border: 2px solid;
font-weight: 600;
margin-bottom: 20px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.network-online {
background: #d4edda;
color: var(--success);
border-color: var(--success);
}
.network-offline {
background: #f8d7da;
color: var(--error);
border-color: var(--error);
}
.test-section {
background: var(--bg-secondary);
padding: 20px;
border: 2px solid var(--border-color);
margin-bottom: 20px;
position: relative;
}
.test-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-primary), transparent);
}
.section-title {
font-size: 14px;
font-weight: 700;
color: var(--accent-primary);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 10px;
text-transform: uppercase;
letter-spacing: 1px;
padding-bottom: 12px;
border-bottom: 2px solid var(--border-color);
}
.test-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.test-card {
border: 2px solid var(--border-color);
padding: 16px;
background: var(--bg-tertiary);
transition: all 0.2s;
}
.test-card:hover {
border-color: var(--accent-primary);
box-shadow: 0 4px 12px rgba(0, 102, 204, 0.15);
}
.card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card-description {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 16px;
line-height: 1.6;
}
.form-group {
margin-bottom: 14px;
}
label {
display: block;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input, select, textarea {
width: 100%;
padding: 8px 10px;
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
transition: all 0.15s;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
textarea {
resize: vertical;
min-height: 60px;
}
button {
width: 100%;
padding: 10px 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:not(:disabled) {
border-color: var(--accent-primary);
color: var(--accent-primary);
background: var(--bg-tertiary);
}
button:active:not(:disabled) {
transform: scale(0.98);
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-create {
border-color: var(--success);
color: var(--success);
}
.btn-create:hover:not(:disabled) {
background: #d4edda;
}
.btn-update {
border-color: var(--info);
color: var(--info);
}
.btn-update:hover:not(:disabled) {
background: #d1ecf1;
}
.btn-delete {
border-color: var(--error);
color: var(--error);
}
.btn-delete:hover:not(:disabled) {
background: #f8d7da;
}
.btn-utility {
border-color: var(--accent-secondary);
color: var(--accent-secondary);
}
.btn-utility:hover:not(:disabled) {
background: #e7d8ff;
}
.result-box {
background: var(--bg-tertiary);
border-left: 3px solid var(--accent-primary);
padding: 12px;
margin-top: 12px;
font-size: 11px;
max-height: 250px;
overflow-y: auto;
line-height: 1.6;
border: 2px solid var(--border-color);
border-left: 3px solid var(--accent-primary);
}
.result-success {
border-left-color: var(--success);
background: #d4edda;
color: var(--success);
}
.result-error {
border-left-color: var(--error);
background: #f8d7da;
color: var(--error);
}
.result-info {
border-left-color: var(--info);
background: #d1ecf1;
color: var(--info);
}
.instructions {
background: #fff3cd;
border-left: 3px solid var(--warning);
padding: 16px;
margin-bottom: 20px;
font-size: 11px;
border: 2px solid #ffeeba;
border-left: 3px solid var(--warning);
}
.instructions h3 {
color: #856404;
margin-bottom: 10px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.instructions ol {
margin-left: 20px;
color: var(--text-secondary);
line-height: 1.8;
}
.instructions a {
color: var(--accent-primary);
text-decoration: none;
border-bottom: 1px solid transparent;
font-weight: 600;
}
.instructions a:hover {
border-bottom-color: var(--accent-primary);
}
.quick-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.quick-actions button {
flex: 1;
min-width: 200px;
}
code {
background: var(--bg-tertiary);
color: var(--accent-primary);
padding: 2px 6px;
border: 1px solid var(--border-color);
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
}
.badge {
display: inline-block;
padding: 3px 10px;
border: 2px solid;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-success {
background: #d4edda;
color: var(--success);
border-color: var(--success);
}
.badge-warning {
background: #fff3cd;
color: #856404;
border-color: var(--warning);
}
.badge-error {
background: #f8d7da;
color: var(--error);
border-color: var(--error);
}
.event-preview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-top: 16px;
}
.event-preview-item {
padding: 12px;
border: 2px solid var(--border-color);
background: var(--bg-tertiary);
font-size: 11px;
transition: all 0.15s;
}
.event-preview-item:hover {
border-color: var(--accent-primary);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 102, 204, 0.1);
}
.event-preview-title {
font-weight: 600;
margin-bottom: 6px;
color: var(--text-primary);
}
.event-preview-id {
font-size: 9px;
color: var(--text-muted);
margin-bottom: 6px;
word-break: break-all;
}
.event-preview-status {
font-size: 10px;
margin-top: 8px;
display: flex;
gap: 6px;
align-items: center;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>OFFLINE MODE TESTING</h1>
<p class="subtitle">// Interactive testing playground for offline-first calendar functionality</p>
<div id="initStatus" class="network-status" style="background: var(--warning); color: #000; display: block; margin-bottom: 8px;">
[⏳] INITIALIZING CALENDAR SYSTEM...
</div>
<div id="networkStatus" class="network-status network-online">
[●] NETWORK: ONLINE
</div>
<div class="instructions">
<h3>TESTING PROTOCOL</h3>
<ol>
<li>Perform CRUD operations below (create, update, delete events)</li>
<li>Open DevTools → Network tab → Check "Offline" to simulate offline mode</li>
<li>Continue performing operations → they will be queued</li>
<li>Open <a href="/test/integrationtesting/sync-visualization.html" target="_blank">Sync Visualization</a> to monitor the queue</li>
<li>Uncheck "Offline" to go back online → operations will sync automatically</li>
<li>Press F5 while offline → verify data persists from IndexedDB</li>
</ol>
</div>
</div>
<!-- Create Event -->
<div class="test-section">
<div class="section-title">CREATE OPERATIONS</div>
<div class="test-grid">
<div class="test-card">
<div class="card-title">Create Timed Event</div>
<div class="card-description">// Creates a new timed event in the calendar</div>
<div class="form-group">
<label>Title</label>
<input type="text" id="createTitle" placeholder="Team Meeting" value="Test Event">
</div>
<div class="form-group">
<label>Start Time</label>
<input type="datetime-local" id="createStart">
</div>
<div class="form-group">
<label>End Time</label>
<input type="datetime-local" id="createEnd">
</div>
<button class="btn-create" onclick="createTimedEvent()">CREATE TIMED EVENT</button>
<div id="createResult"></div>
</div>
<div class="test-card">
<div class="card-title">Create All-Day Event</div>
<div class="card-description">// Creates a new all-day event</div>
<div class="form-group">
<label>Title</label>
<input type="text" id="createAllDayTitle" placeholder="Holiday" value="All-Day Test">
</div>
<div class="form-group">
<label>Date</label>
<input type="date" id="createAllDayDate">
</div>
<button class="btn-create" onclick="createAllDayEvent()">CREATE ALL-DAY EVENT</button>
<div id="createAllDayResult"></div>
</div>
</div>
</div>
<!-- Update Event -->
<div class="test-section">
<div class="section-title">UPDATE OPERATIONS</div>
<div class="test-grid">
<div class="test-card">
<div class="card-title">Update Event Title</div>
<div class="card-description">// Update the title of an existing event</div>
<div class="form-group">
<label>Event ID</label>
<input type="text" id="updateEventId" placeholder="event_123456">
</div>
<div class="form-group">
<label>New Title</label>
<input type="text" id="updateTitle" placeholder="Updated Meeting">
</div>
<button class="btn-update" onclick="updateEventTitle()">UPDATE TITLE</button>
<div id="updateTitleResult"></div>
</div>
<div class="test-card">
<div class="card-title">Toggle All-Day Status</div>
<div class="card-description">// Convert between timed and all-day event</div>
<div class="form-group">
<label>Event ID</label>
<input type="text" id="toggleEventId" placeholder="event_123456">
</div>
<button class="btn-update" onclick="toggleAllDay()">TOGGLE ALL-DAY</button>
<div id="toggleResult"></div>
</div>
</div>
</div>
<!-- Delete Event -->
<div class="test-section">
<div class="section-title">DELETE OPERATIONS</div>
<div class="test-grid">
<div class="test-card">
<div class="card-title">Delete by ID</div>
<div class="card-description">// Permanently delete an event</div>
<div class="form-group">
<label>Event ID</label>
<input type="text" id="deleteEventId" placeholder="event_123456">
</div>
<button class="btn-delete" onclick="deleteEvent()">DELETE EVENT</button>
<div id="deleteResult"></div>
</div>
</div>
</div>
<!-- Utilities -->
<div class="test-section">
<div class="section-title">UTILITY OPERATIONS</div>
<div class="quick-actions">
<button class="btn-utility" onclick="listAllEvents()">LIST ALL EVENTS</button>
<button class="btn-utility" onclick="showQueue()">SHOW QUEUE</button>
<button class="btn-utility" onclick="triggerSync()">TRIGGER SYNC</button>
<button class="btn-delete" onclick="clearAllData()">CLEAR ALL DATA</button>
</div>
<div id="utilityResult"></div>
</div>
<!-- Event Preview -->
<div class="test-section">
<div class="section-title">
EVENT PREVIEW
<button class="btn-utility" onclick="refreshPreview()" style="width: auto; padding: 6px 12px; font-size: 10px; margin-left: auto;">REFRESH</button>
</div>
<div id="eventPreview" class="event-preview"></div>
</div>
</div>
<!-- Load Test Initialization Script -->
<script src="test-init.js"></script>
<script>
// Wait for calendar to initialize
let calendarReady = false;
let initCheckInterval;
function waitForCalendar() {
return new Promise((resolve) => {
if (window.calendarDebug?.indexedDB) {
calendarReady = true;
const initStatus = document.getElementById('initStatus');
if (initStatus) {
initStatus.style.display = 'none';
}
resolve();
return;
}
initCheckInterval = setInterval(() => {
if (window.calendarDebug?.indexedDB) {
calendarReady = true;
clearInterval(initCheckInterval);
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(initCheckInterval);
console.error('Calendar failed to initialize within 10 seconds');
const initStatus = document.getElementById('initStatus');
if (initStatus) {
initStatus.style.background = 'var(--error)';
initStatus.style.color = '#fff';
initStatus.textContent = '[✗] CALENDAR SYSTEM FAILED TO INITIALIZE';
}
document.getElementById('eventPreview').innerHTML = `
<div style="color: var(--error); padding: 20px; text-align: center;">
[ERROR] Calendar system failed to initialize<br>
<small>Check console for details</small>
</div>
`;
}
}, 10000);
});
}
// Initialize datetime inputs with current time
function initDateTimeInputs() {
const now = new Date();
const start = new Date(now.getTime() + 60 * 60 * 1000); // +1 hour
const end = new Date(start.getTime() + 60 * 60 * 1000); // +1 hour from start
document.getElementById('createStart').value = formatDateTimeLocal(start);
document.getElementById('createEnd').value = formatDateTimeLocal(end);
document.getElementById('createAllDayDate').value = formatDateLocal(now);
}
function formatDateTimeLocal(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function formatDateLocal(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// Network status monitoring
function updateNetworkStatus() {
const statusDiv = document.getElementById('networkStatus');
const isOnline = navigator.onLine;
statusDiv.textContent = isOnline ? '[●] NETWORK: ONLINE' : '[●] NETWORK: OFFLINE';
statusDiv.className = `network-status ${isOnline ? 'network-online' : 'network-offline'}`;
}
window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);
// Get EventManager
function getEventManager() {
if (!window.calendarDebug?.eventManager) {
throw new Error('Calendar not loaded - window.calendarDebug.eventManager not available');
}
return window.calendarDebug.eventManager;
}
// Create Timed Event
async function createTimedEvent() {
const result = document.getElementById('createResult');
try {
const title = document.getElementById('createTitle').value;
const start = new Date(document.getElementById('createStart').value);
const end = new Date(document.getElementById('createEnd').value);
const eventManager = getEventManager();
const newEvent = await eventManager.addEvent({
title,
start,
end,
type: 'meeting',
allDay: false,
syncStatus: 'pending'
});
showResult(result, 'success', `[OK] Event created<br>ID: ${newEvent.id}<br>Status: ${newEvent.syncStatus}`);
await refreshPreview();
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Create All-Day Event
async function createAllDayEvent() {
const result = document.getElementById('createAllDayResult');
try {
const title = document.getElementById('createAllDayTitle').value;
const dateStr = document.getElementById('createAllDayDate').value;
const start = new Date(dateStr + 'T00:00:00');
const end = new Date(dateStr + 'T23:59:59');
const eventManager = getEventManager();
const newEvent = await eventManager.addEvent({
title,
start,
end,
type: 'holiday',
allDay: true,
syncStatus: 'pending'
});
showResult(result, 'success', `[OK] All-day event created<br>ID: ${newEvent.id}`);
await refreshPreview();
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Update Event Title
async function updateEventTitle() {
const result = document.getElementById('updateTitleResult');
try {
const eventId = document.getElementById('updateEventId').value;
const newTitle = document.getElementById('updateTitle').value;
if (!eventId) {
showResult(result, 'error', '[ERROR] Please enter an event ID');
return;
}
const eventManager = getEventManager();
const updated = await eventManager.updateEvent(eventId, { title: newTitle });
if (updated) {
showResult(result, 'success', `[OK] Event updated<br>New title: "${updated.title}"`);
await refreshPreview();
} else {
showResult(result, 'error', '[ERROR] Event not found');
}
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Toggle All-Day Status
async function toggleAllDay() {
const result = document.getElementById('toggleResult');
try {
const eventId = document.getElementById('toggleEventId').value;
if (!eventId) {
showResult(result, 'error', '[ERROR] Please enter an event ID');
return;
}
const eventManager = getEventManager();
const event = await eventManager.getEventById(eventId);
if (!event) {
showResult(result, 'error', '[ERROR] Event not found');
return;
}
const updated = await eventManager.updateEvent(eventId, {
allDay: !event.allDay
});
showResult(result, 'success', `[OK] Event toggled<br>Now: ${updated.allDay ? 'all-day' : 'timed'}`);
await refreshPreview();
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Delete Event
async function deleteEvent() {
const result = document.getElementById('deleteResult');
try {
const eventId = document.getElementById('deleteEventId').value;
if (!eventId) {
showResult(result, 'error', '[ERROR] Please enter an event ID');
return;
}
if (!confirm(`Delete event ${eventId}?`)) {
return;
}
const eventManager = getEventManager();
const success = await eventManager.deleteEvent(eventId);
if (success) {
showResult(result, 'success', `[OK] Event deleted<br>ID: ${eventId}`);
await refreshPreview();
} else {
showResult(result, 'error', '[ERROR] Event not found');
}
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// List All Events
async function listAllEvents() {
const result = document.getElementById('utilityResult');
try {
const db = window.calendarDebug.indexedDB;
const events = await db.getAllEvents();
const html = `
<strong>[EVENTS] Total: ${events.length}</strong><br><br>
${events.map(e => `
<div style="margin-bottom: 10px; padding: 10px; background: var(--bg-secondary); border: 2px solid var(--border-color);">
<strong>${e.title}</strong><br>
<span style="font-size: 10px; color: var(--text-muted);">ID: ${e.id}</span><br>
<span class="badge badge-${e.syncStatus === 'synced' ? 'success' : e.syncStatus === 'pending' ? 'warning' : 'error'}">
${e.syncStatus}
</span>
${e.allDay ? '[ALL-DAY]' : '[TIMED]'}
</div>
`).join('')}
`;
showResult(result, 'info', html);
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Show Queue
async function showQueue() {
const result = document.getElementById('utilityResult');
try {
const queue = window.calendarDebug.queue;
const items = await queue.getAll();
const html = `
<strong>[QUEUE] Size: ${items.length}</strong><br><br>
${items.length === 0 ? '[INFO] Queue is empty' : items.map(item => `
<div style="margin-bottom: 10px; padding: 10px; background: var(--bg-secondary); border: 2px solid var(--border-color); border-left: 3px solid var(--accent-primary);">
<strong>${item.type.toUpperCase()}</strong> → Event ${item.eventId}<br>
<span style="font-size: 10px; color: var(--text-muted);">Retry: ${item.retryCount}/5</span>
</div>
`).join('')}
`;
showResult(result, 'info', html);
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Trigger Sync
async function triggerSync() {
const result = document.getElementById('utilityResult');
const timestamp = new Date().toLocaleTimeString('da-DK');
try {
const syncManager = window.calendarDebug.syncManager;
await syncManager.triggerManualSync();
showResult(result, 'success', `[OK] Sync triggered at ${timestamp}<br>Check sync-visualization.html for details`);
await refreshPreview();
} catch (error) {
const isOffline = error.message.includes('offline');
const icon = isOffline ? '⚠️' : '❌';
showResult(result, 'error', `${icon} [ERROR] Sync failed at ${timestamp}<br>${error.message}`);
}
}
// Clear All Data
async function clearAllData() {
if (!confirm('⚠️ WARNING: Delete ALL events and queue? This cannot be undone!')) {
return;
}
const result = document.getElementById('utilityResult');
try {
const db = window.calendarDebug.indexedDB;
const queue = window.calendarDebug.queue;
await queue.clear();
const events = await db.getAllEvents();
for (const event of events) {
await db.deleteEvent(event.id);
}
showResult(result, 'success', '[OK] All data cleared');
await refreshPreview();
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Refresh Event Preview
async function refreshPreview() {
const preview = document.getElementById('eventPreview');
try {
const db = window.calendarDebug.indexedDB;
const events = await db.getAllEvents();
if (events.length === 0) {
preview.innerHTML = '<div style="grid-column: 1/-1; text-align: center; color: var(--text-muted); padding: 40px; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; font-weight: 600;">[EMPTY] No events in IndexedDB</div>';
return;
}
preview.innerHTML = events.map(e => `
<div class="event-preview-item">
<div class="event-preview-title">${e.title}</div>
<div class="event-preview-id">${e.id}</div>
<div class="event-preview-status">
<span class="badge badge-${e.syncStatus === 'synced' ? 'success' : e.syncStatus === 'pending' ? 'warning' : 'error'}">
${e.syncStatus}
</span>
<span>${e.allDay ? '[ALL-DAY]' : '[TIMED]'}</span>
</div>
</div>
`).join('');
} catch (error) {
preview.innerHTML = `<div style="color: var(--error);">[ERROR] ${error.message}</div>`;
}
}
// Show Result Helper
function showResult(element, type, message) {
element.innerHTML = `<div class="result-box result-${type}">${message}</div>`;
}
// Initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', async () => {
initDateTimeInputs();
updateNetworkStatus();
await waitForCalendar();
refreshPreview();
});
} else {
(async () => {
initDateTimeInputs();
updateNetworkStatus();
await waitForCalendar();
refreshPreview();
})();
}
</script>
</body>
</html>