tractatus/public/js/version-manager.js
TheFlow e217c860ef fix: Hide feedback FAB on mobile, add to drawer, persist install dismissal
FAB overlaps PWA install prompt and update notifications on small screens.
Mobile users now access feedback via the navbar drawer instead. Install
prompt dismissal persists in localStorage and is skipped in standalone mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 05:55:30 +13:00

429 lines
14 KiB
JavaScript

/**
* Tractatus Version Manager
* - Registers service worker
* - Checks for updates every hour
* - Shows update notifications
* - Manages PWA install prompts
*/
class VersionManager {
constructor() {
this.serviceWorker = null;
this.deferredInstallPrompt = null;
this.updateCheckInterval = null;
this.currentVersion = null;
this.init();
}
async init() {
// Only run in browsers that support service workers
if (!('serviceWorker' in navigator)) {
console.log('[VersionManager] Service workers not supported');
return;
}
try {
// Register service worker
await this.registerServiceWorker();
// Check for updates immediately
await this.checkForUpdates();
// Check for updates every hour
this.updateCheckInterval = setInterval(() => {
this.checkForUpdates();
}, 3600000); // 1 hour
// Listen for PWA install prompt
this.setupInstallPrompt();
// Listen for service worker messages
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'UPDATE_AVAILABLE') {
this.showUpdateNotification(event.data);
}
});
} catch (error) {
console.error('[VersionManager] Initialization failed:', error);
}
}
async registerServiceWorker() {
try {
// Cache-bust service worker to force update: v0.1.5 nuclear reset
const registration = await navigator.serviceWorker.register('/service-worker.js?v=0.1.6');
this.serviceWorker = registration;
console.log('[VersionManager] Service worker registered');
// Check for updates when service worker updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
this.showUpdateNotification({
updateAvailable: true,
currentVersion: this.currentVersion,
serverVersion: 'latest'
});
}
});
});
} catch (error) {
console.error('[VersionManager] Service worker registration failed:', error);
}
}
async checkForUpdates() {
try {
const response = await fetch('/version.json', { cache: 'no-store' });
const versionInfo = await response.json();
// Get current version from localStorage or default
const storedVersion = localStorage.getItem('tractatus_version') || '0.0.0';
this.currentVersion = storedVersion;
if (storedVersion !== versionInfo.version) {
console.log('[VersionManager] Update available:', versionInfo.version);
this.showUpdateNotification({
updateAvailable: true,
currentVersion: storedVersion,
serverVersion: versionInfo.version,
changelog: versionInfo.changelog,
forceUpdate: versionInfo.forceUpdate
});
}
} catch (error) {
console.error('[VersionManager] Version check failed:', error);
}
}
showUpdateNotification(versionInfo) {
// Don't show if notification already visible
if (document.getElementById('tractatus-update-notification')) {
return;
}
const notification = document.createElement('div');
notification.id = 'tractatus-update-notification';
notification.className = 'fixed bottom-0 left-0 right-0 bg-blue-600 text-white px-4 py-3 shadow-lg z-50 transform transition-transform duration-300 translate-y-full';
notification.innerHTML = `
<div class="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-4">
<div class="flex items-start flex-1">
<svg class="w-6 h-6 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<p class="font-semibold">Update Available</p>
<p class="text-sm text-blue-100">
A new version of Tractatus Framework is available
${versionInfo.serverVersion ? `(v${versionInfo.serverVersion})` : ''}
</p>
${versionInfo.changelog ? `
<details class="mt-2 text-sm">
<summary class="cursor-pointer text-blue-100 hover:text-white">What's new?</summary>
<ul class="mt-2 ml-4 list-disc text-blue-100">
${versionInfo.changelog.map(item => `<li>${item}</li>`).join('')}
</ul>
</details>
` : ''}
</div>
</div>
<div class="flex gap-3">
${versionInfo.forceUpdate ? `
<button id="update-now-btn" class="bg-white text-blue-600 px-6 py-2 rounded-lg font-semibold hover:bg-blue-50 transition">
Update Now
</button>
` : `
<button id="update-later-btn" class="text-white hover:text-blue-100 transition px-3">
Later
</button>
<button id="update-reload-btn" class="bg-white text-blue-600 px-6 py-2 rounded-lg font-semibold hover:bg-blue-50 transition">
Reload
</button>
`}
</div>
</div>
`;
document.body.appendChild(notification);
// Add event listeners (CSP compliant)
const updateNowBtn = document.getElementById('update-now-btn');
const updateLaterBtn = document.getElementById('update-later-btn');
const updateReloadBtn = document.getElementById('update-reload-btn');
if (updateNowBtn) {
updateNowBtn.addEventListener('click', () => this.applyUpdate());
}
if (updateLaterBtn) {
updateLaterBtn.addEventListener('click', () => this.dismissUpdate());
}
if (updateReloadBtn) {
updateReloadBtn.addEventListener('click', () => this.applyUpdate());
}
// Animate in
setTimeout(() => {
notification.classList.remove('translate-y-full');
}, 100);
// Auto-reload for forced updates after 10 seconds
if (versionInfo.forceUpdate) {
setTimeout(() => {
this.applyUpdate();
}, 10000);
}
}
applyUpdate() {
// Store new version
fetch('/version.json', { cache: 'no-store' })
.then(response => response.json())
.then(versionInfo => {
localStorage.setItem('tractatus_version', versionInfo.version);
// Tell service worker to skip waiting
if (this.serviceWorker) {
this.serviceWorker.waiting?.postMessage({ type: 'SKIP_WAITING' });
}
// Reload page
window.location.reload();
});
}
dismissUpdate() {
const notification = document.getElementById('tractatus-update-notification');
if (notification) {
notification.classList.add('translate-y-full');
setTimeout(() => {
notification.remove();
}, 300);
}
}
setupInstallPrompt() {
// Skip entirely if already running as installed PWA
if (window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true) {
return;
}
// Listen for beforeinstallprompt event
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent Chrome 67 and earlier from automatically showing the prompt
e.preventDefault();
// Stash the event so it can be triggered later
this.deferredInstallPrompt = e;
// Check if user has dismissed install prompt before
const dismissed = localStorage.getItem('tractatus_install_dismissed');
if (!dismissed) {
// Show install prompt after 30 seconds
setTimeout(() => {
this.showInstallPrompt();
}, 30000);
}
});
// Detect if app was installed
window.addEventListener('appinstalled', () => {
console.log('[VersionManager] PWA installed');
this.deferredInstallPrompt = null;
localStorage.setItem('tractatus_install_dismissed', 'true');
// Hide install prompt if visible
const prompt = document.getElementById('tractatus-install-prompt');
if (prompt) {
prompt.remove();
}
});
}
showInstallPrompt() {
if (!this.deferredInstallPrompt) {
return;
}
// Don't show if already installed or on iOS Safari (handles differently)
if (window.matchMedia('(display-mode: standalone)').matches) {
return;
}
const prompt = document.createElement('div');
prompt.id = 'tractatus-install-prompt';
prompt.className = 'fixed bottom-0 left-0 right-0 bg-gradient-to-r from-purple-600 to-blue-600 text-white px-4 py-3 shadow-lg z-50 transform transition-transform duration-300 translate-y-full';
prompt.innerHTML = `
<div class="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-4">
<div class="flex items-start flex-1">
<svg class="w-6 h-6 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<div>
<p class="font-semibold">Install Tractatus App</p>
<p class="text-sm text-purple-100">
Add to your home screen for quick access and offline support
</p>
</div>
</div>
<div class="flex gap-3">
<button id="dismiss-install-btn" class="text-white hover:text-purple-100 transition px-3">
Not Now
</button>
<button id="install-app-btn" class="bg-white text-purple-600 px-6 py-2 rounded-lg font-semibold hover:bg-purple-50 transition">
Install
</button>
</div>
</div>
`;
document.body.appendChild(prompt);
// Add event listeners (CSP compliant)
const dismissBtn = document.getElementById('dismiss-install-btn');
const installBtn = document.getElementById('install-app-btn');
if (dismissBtn) {
dismissBtn.addEventListener('click', () => this.dismissInstallPrompt());
}
if (installBtn) {
installBtn.addEventListener('click', () => this.installApp());
}
// Animate in
setTimeout(() => {
prompt.classList.remove('translate-y-full');
}, 100);
}
async installApp() {
if (!this.deferredInstallPrompt) {
// Show helpful feedback if installation isn't available
this.showInstallUnavailableMessage();
return;
}
// Show the install prompt
this.deferredInstallPrompt.prompt();
// Wait for the user to respond to the prompt
const { outcome } = await this.deferredInstallPrompt.userChoice;
console.log(`[VersionManager] User response: ${outcome}`);
// Clear the deferredInstallPrompt
this.deferredInstallPrompt = null;
// Hide the prompt
this.dismissInstallPrompt();
}
showInstallUnavailableMessage() {
// Check if app is already installed
const isInstalled = window.matchMedia('(display-mode: standalone)').matches;
// Don't show message if it already exists
if (document.getElementById('tractatus-install-unavailable')) {
return;
}
const message = document.createElement('div');
message.id = 'tractatus-install-unavailable';
message.className = 'fixed bottom-0 left-0 right-0 bg-gray-800 text-white px-4 py-3 shadow-lg z-50 transform transition-transform duration-300 translate-y-full';
if (isInstalled) {
message.innerHTML = `
<div class="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-4">
<div class="flex items-start flex-1">
<svg class="w-6 h-6 mr-3 flex-shrink-0 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<p class="font-semibold">Already Installed</p>
<p class="text-sm text-gray-300">
Tractatus is already installed on your device. You're using it right now!
</p>
</div>
</div>
<button id="dismiss-unavailable-btn" class="text-white hover:text-gray-300 transition px-3">
Okay
</button>
</div>
`;
} else {
message.innerHTML = `
<div class="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-4">
<div class="flex items-start flex-1">
<svg class="w-6 h-6 mr-3 flex-shrink-0 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<p class="font-semibold">Installation Not Available</p>
<p class="text-sm text-gray-300">
Your browser doesn't currently support app installation. Try using Chrome, Edge, or Safari on a supported device.
</p>
</div>
</div>
<button id="dismiss-unavailable-btn" class="text-white hover:text-gray-300 transition px-3">
Okay
</button>
</div>
`;
}
document.body.appendChild(message);
// Add event listener for dismiss button
const dismissBtn = document.getElementById('dismiss-unavailable-btn');
if (dismissBtn) {
dismissBtn.addEventListener('click', () => {
message.classList.add('translate-y-full');
setTimeout(() => {
message.remove();
}, 300);
});
}
// Animate in
setTimeout(() => {
message.classList.remove('translate-y-full');
}, 100);
// Auto-dismiss after 8 seconds
setTimeout(() => {
if (message.parentElement) {
message.classList.add('translate-y-full');
setTimeout(() => {
message.remove();
}, 300);
}
}, 8000);
}
dismissInstallPrompt() {
const prompt = document.getElementById('tractatus-install-prompt');
if (prompt) {
prompt.classList.add('translate-y-full');
setTimeout(() => {
prompt.remove();
}, 300);
}
// Remember dismissal persistently across sessions
localStorage.setItem('tractatus_install_dismissed', 'true');
}
}
// Initialize version manager on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
window.versionManager = new VersionManager();
});
} else {
window.versionManager = new VersionManager();
}