Spaces:
Running
Running
| <html lang="en" class="h-full"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>VIBE Monitor | Privacy-Focused Activity Tracking</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <link rel="manifest" href="/manifest.json"> | |
| <style> | |
| /* Custom styles that can't be done with Tailwind */ | |
| .chart-container { | |
| height: 200px; | |
| position: relative; | |
| } | |
| .progress-ring__circle { | |
| transition: stroke-dashoffset 0.35s; | |
| transform: rotate(-90deg); | |
| transform-origin: 50% 50%; | |
| } | |
| .fade-enter-active, .fade-leave-active { | |
| transition: opacity 0.3s; | |
| } | |
| .fade-enter, .fade-leave-to { | |
| opacity: 0; | |
| } | |
| .slide-up-enter-active, .slide-up-leave-active { | |
| transition: all 0.3s ease; | |
| } | |
| .slide-up-enter, .slide-up-leave-to { | |
| transform: translateY(20px); | |
| opacity: 0; | |
| } | |
| #batteryGauge { | |
| width: 60px; | |
| height: 30px; | |
| border: 2px solid currentColor; | |
| border-radius: 4px; | |
| position: relative; | |
| } | |
| #batteryGauge::after { | |
| content: ''; | |
| position: absolute; | |
| right: -6px; | |
| top: 8px; | |
| width: 3px; | |
| height: 10px; | |
| background: currentColor; | |
| border-radius: 0 2px 2px 0; | |
| } | |
| #batteryLevel { | |
| height: 100%; | |
| background: currentColor; | |
| transition: width 0.5s; | |
| } | |
| </style> | |
| </head> | |
| <body class="h-full bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-200"> | |
| <div id="app" class="min-h-full flex flex-col"> | |
| <!-- Consent Modal --> | |
| <div id="consentModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6"> | |
| <div class="flex items-center mb-4"> | |
| <i class="fas fa-shield-alt text-blue-500 mr-3 text-xl"></i> | |
| <h2 class="text-xl font-bold">Privacy-First Monitoring</h2> | |
| </div> | |
| <p class="mb-4 text-gray-700 dark:text-gray-300"> | |
| VIBE Monitor helps you track your device usage while respecting your privacy. All data stays on your device unless you choose to export it. | |
| </p> | |
| <div class="bg-blue-50 dark:bg-blue-900 dark:bg-opacity-30 p-4 rounded mb-4"> | |
| <h3 class="font-semibold text-blue-800 dark:text-blue-200 mb-2">We'll track:</h3> | |
| <ul class="list-disc pl-5 text-sm text-blue-700 dark:text-blue-300"> | |
| <li>Screen time & app visibility</li> | |
| <li>Device information (browser, battery)</li> | |
| <li>Motion activity (if enabled)</li> | |
| <li>Location (only with permission)</li> | |
| </ul> | |
| </div> | |
| <div class="flex flex-col space-y-3"> | |
| <button id="acceptAll" class="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg font-medium transition"> | |
| Accept All Tracking | |
| </button> | |
| <button id="acceptEssential" class="border border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900 dark:hover:bg-opacity-30 py-2 px-4 rounded-lg font-medium transition"> | |
| Essential Tracking Only | |
| </button> | |
| <button id="rejectAll" class="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 py-2 px-4 rounded-lg font-medium transition"> | |
| Reject All | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main App --> | |
| <header class="bg-white dark:bg-gray-800 shadow-sm"> | |
| <div class="container mx-auto px-4 py-3 flex justify-between items-center"> | |
| <div class="flex items-center"> | |
| <i class="fas fa-chart-line text-blue-500 mr-2"></i> | |
| <h1 class="font-bold text-lg">VIBE Monitor</h1> | |
| </div> | |
| <div class="flex items-center space-x-3"> | |
| <button id="themeToggle" class="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"> | |
| <i class="fas fa-moon dark:hidden"></i> | |
| <i class="fas fa-sun hidden dark:block"></i> | |
| </button> | |
| <button id="exportBtn" class="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700" title="Export Data"> | |
| <i class="fas fa-download"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| <main class="flex-grow container mx-auto px-4 py-6"> | |
| <!-- Stats Overview --> | |
| <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4"> | |
| <div class="flex items-center justify-between"> | |
| <div> | |
| <p class="text-sm text-gray-500 dark:text-gray-400">Screen Time</p> | |
| <p id="screenTime" class="text-2xl font-bold">0h 0m</p> | |
| </div> | |
| <div class="text-blue-500"> | |
| <i class="fas fa-desktop text-xl"></i> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4"> | |
| <div class="flex items-center justify-between"> | |
| <div> | |
| <p class="text-sm text-gray-500 dark:text-gray-400">Active Sessions</p> | |
| <p id="sessionCount" class="text-2xl font-bold">0</p> | |
| </div> | |
| <div class="text-green-500"> | |
| <i class="fas fa-user-clock text-xl"></i> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4"> | |
| <div class="flex items-center justify-between"> | |
| <div> | |
| <p class="text-sm text-gray-500 dark:text-gray-400">Battery</p> | |
| <p id="batteryPercent" class="text-2xl font-bold">--%</p> | |
| </div> | |
| <div id="batteryGauge" class="ml-2"> | |
| <div id="batteryLevel" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4"> | |
| <div class="flex items-center justify-between"> | |
| <div> | |
| <p class="text-sm text-gray-500 dark:text-gray-400">Motion</p> | |
| <p id="motionStatus" class="text-2xl font-bold">--</p> | |
| </div> | |
| <div class="text-purple-500"> | |
| <i class="fas fa-running text-xl"></i> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Charts Section --> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> | |
| <!-- Screen Time Chart --> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4"> | |
| <h2 class="font-semibold mb-3 flex items-center"> | |
| <i class="fas fa-chart-pie text-blue-500 mr-2"></i> | |
| Screen Time Distribution | |
| </h2> | |
| <div class="chart-container"> | |
| <canvas id="screenTimeChart"></canvas> | |
| </div> | |
| </div> | |
| <!-- Activity Timeline --> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4"> | |
| <h2 class="font-semibold mb-3 flex items-center"> | |
| <i class="fas fa-history text-green-500 mr-2"></i> | |
| Activity Timeline | |
| </h2> | |
| <div class="chart-container"> | |
| <canvas id="activityTimeline"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Detailed Stats --> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden mb-6"> | |
| <div class="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex justify-between items-center"> | |
| <h2 class="font-semibold flex items-center"> | |
| <i class="fas fa-info-circle text-purple-500 mr-2"></i> | |
| Device & Session Details | |
| </h2> | |
| <button id="refreshDetails" class="text-sm text-blue-500 hover:text-blue-700 dark:hover:text-blue-400"> | |
| <i class="fas fa-sync-alt mr-1"></i> Refresh | |
| </button> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-gray-200 dark:divide-gray-700"> | |
| <div class="p-4"> | |
| <h3 class="font-medium mb-2 text-gray-700 dark:text-gray-300">Device Information</h3> | |
| <div class="space-y-2 text-sm"> | |
| <div class="flex justify-between"> | |
| <span class="text-gray-500 dark:text-gray-400">OS</span> | |
| <span id="deviceOS" class="font-medium">--</span> | |
| </div> | |
| <div class="flex justify-between"> | |
| <span class="text-gray-500 dark:text-gray-400">Browser</span> | |
| <span id="deviceBrowser" class="font-medium">--</span> | |
| </div> | |
| <div class="flex justify-between"> | |
| <span class="text-gray-500 dark:text-gray-400">Screen</span> | |
| <span id="deviceScreen" class="font-medium">--</span> | |
| </div> | |
| <div class="flex justify-between"> | |
| <span class="text-gray-500 dark:text-gray-400">Connection</span> | |
| <span id="deviceConnection" class="font-medium">--</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="p-4"> | |
| <h3 class="font-medium mb-2 text-gray-700 dark:text-gray-300">Current Session</h3> | |
| <div class="space-y-2 text-sm"> | |
| <div class="flex justify-between"> | |
| <span class="text-gray-500 dark:text-gray-400">Start Time</span> | |
| <span id="sessionStart" class="font-medium">--</span> | |
| </div> | |
| <div class="flex justify-between"> | |
| <span class="text-gray-500 dark:text-gray-400">Duration</span> | |
| <span id="sessionDuration" class="font-medium">--</span> | |
| </div> | |
| <div class="flex justify-between"> | |
| <span class="text-gray-500 dark:text-gray-400">Visibility</span> | |
| <span id="sessionVisibility" class="font-medium">--</span> | |
| </div> | |
| <div class="flex justify-between"> | |
| <span class="text-gray-500 dark:text-gray-400">Location</span> | |
| <span id="sessionLocation" class="font-medium">--</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Recent Activity Log --> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden"> | |
| <div class="border-b border-gray-200 dark:border-gray-700 px-4 py-3"> | |
| <h2 class="font-semibold flex items-center"> | |
| <i class="fas fa-list-ul text-orange-500 mr-2"></i> | |
| Recent Activity Log | |
| </h2> | |
| </div> | |
| <div class="divide-y divide-gray-200 dark:divide-gray-700"> | |
| <div id="activityLog" class="p-4 text-sm"> | |
| <p class="text-gray-500 dark:text-gray-400 text-center py-4">No activity recorded yet</p> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <!-- Export Modal --> | |
| <div id="exportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50 hidden"> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h2 class="text-lg font-bold">Export Your Data</h2> | |
| <button id="closeExportModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <p class="mb-4 text-gray-700 dark:text-gray-300"> | |
| Choose the format you'd like to export your activity data in. All data remains on your device until you choose to export it. | |
| </p> | |
| <div class="flex flex-col space-y-3"> | |
| <button id="exportJSON" class="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg font-medium transition flex items-center justify-center"> | |
| <i class="fas fa-file-code mr-2"></i> Export as JSON | |
| </button> | |
| <button id="exportCSV" class="bg-green-600 hover:bg-green-700 text-white py-2 px-4 rounded-lg font-medium transition flex items-center justify-center"> | |
| <i class="fas fa-file-csv mr-2"></i> Export as CSV | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <script> | |
| // Main Application | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Initialize the app | |
| const app = new VIBEMonitor(); | |
| app.init(); | |
| }); | |
| class VIBEMonitor { | |
| constructor() { | |
| this.consentGiven = false; | |
| this.trackingEnabled = false; | |
| this.locationEnabled = false; | |
| this.motionEnabled = false; | |
| this.currentSession = null; | |
| this.sessions = []; | |
| this.activityLog = []; | |
| this.deviceInfo = {}; | |
| this.batteryInfo = {}; | |
| this.motionData = {}; | |
| this.locationData = {}; | |
| this.charts = {}; | |
| this.theme = localStorage.getItem('theme') || 'light'; | |
| } | |
| init() { | |
| // Set up theme | |
| this.setTheme(this.theme); | |
| // Initialize UI event listeners | |
| this.initEventListeners(); | |
| // Show consent modal if not already given | |
| if (!localStorage.getItem('consentGiven')) { | |
| document.getElementById('consentModal').classList.remove('hidden'); | |
| } else { | |
| this.consentGiven = true; | |
| this.trackingEnabled = localStorage.getItem('trackingEnabled') === 'true'; | |
| this.locationEnabled = localStorage.getItem('locationEnabled') === 'true'; | |
| this.motionEnabled = localStorage.getItem('motionEnabled') === 'true'; | |
| this.startMonitoring(); | |
| } | |
| // Load any existing data | |
| this.loadData(); | |
| this.updateUI(); | |
| } | |
| initEventListeners() { | |
| // Consent modal buttons | |
| document.getElementById('acceptAll').addEventListener('click', () => { | |
| this.giveConsent(true, true); | |
| }); | |
| document.getElementById('acceptEssential').addEventListener('click', () => { | |
| this.giveConsent(true, false); | |
| }); | |
| document.getElementById('rejectAll').addEventListener('click', () => { | |
| this.giveConsent(false, false); | |
| }); | |
| // Theme toggle | |
| document.getElementById('themeToggle').addEventListener('click', () => { | |
| this.toggleTheme(); | |
| }); | |
| // Export buttons | |
| document.getElementById('exportBtn').addEventListener('click', () => { | |
| document.getElementById('exportModal').classList.remove('hidden'); | |
| }); | |
| document.getElementById('closeExportModal').addEventListener('click', () => { | |
| document.getElementById('exportModal').classList.add('hidden'); | |
| }); | |
| document.getElementById('exportJSON').addEventListener('click', () => { | |
| this.exportData('json'); | |
| }); | |
| document.getElementById('exportCSV').addEventListener('click', () => { | |
| this.exportData('csv'); | |
| }); | |
| // Refresh details | |
| document.getElementById('refreshDetails').addEventListener('click', () => { | |
| this.updateDeviceInfo(); | |
| this.updateUI(); | |
| }); | |
| // Page visibility changes | |
| document.addEventListener('visibilitychange', () => { | |
| if (!this.trackingEnabled) return; | |
| const now = new Date(); | |
| const isVisible = document.visibilityState === 'visible'; | |
| this.logActivity(isVisible ? 'Page became visible' : 'Page became hidden'); | |
| if (this.currentSession) { | |
| if (isVisible) { | |
| // Resume session | |
| this.currentSession.intervals.push({ | |
| start: now, | |
| end: null, | |
| active: true | |
| }); | |
| } else { | |
| // Pause session | |
| if (this.currentSession.intervals.length > 0) { | |
| const lastInterval = this.currentSession.intervals[this.currentSession.intervals.length - 1]; | |
| if (lastInterval.end === null) { | |
| lastInterval.end = now; | |
| } | |
| } | |
| } | |
| } | |
| this.updateUI(); | |
| this.saveData(); | |
| }); | |
| } | |
| giveConsent(tracking, location) { | |
| this.consentGiven = true; | |
| this.trackingEnabled = tracking; | |
| this.locationEnabled = location && tracking; | |
| this.motionEnabled = tracking; | |
| localStorage.setItem('consentGiven', 'true'); | |
| localStorage.setItem('trackingEnabled', tracking); | |
| localStorage.setItem('locationEnabled', location && tracking); | |
| localStorage.setItem('motionEnabled', tracking); | |
| document.getElementById('consentModal').classList.add('hidden'); | |
| if (tracking) { | |
| this.startMonitoring(); | |
| } | |
| this.logActivity('User gave consent for tracking'); | |
| this.updateUI(); | |
| } | |
| startMonitoring() { | |
| // Start a new session | |
| this.startNewSession(); | |
| // Initialize device info | |
| this.updateDeviceInfo(); | |
| // Set up battery monitoring | |
| this.initBatteryMonitoring(); | |
| // Set up motion monitoring if enabled | |
| if (this.motionEnabled) { | |
| this.initMotionMonitoring(); | |
| } | |
| // Set up location monitoring if enabled | |
| if (this.locationEnabled) { | |
| this.initLocationMonitoring(); | |
| } | |
| // Set up periodic updates | |
| setInterval(() => { | |
| this.updateUI(); | |
| this.saveData(); | |
| }, 5000); | |
| this.logActivity('Monitoring started'); | |
| } | |
| startNewSession() { | |
| const now = new Date(); | |
| this.currentSession = { | |
| start: now, | |
| intervals: [{ | |
| start: now, | |
| end: null, | |
| active: true | |
| }], | |
| deviceInfo: {}, | |
| locationData: [] | |
| }; | |
| this.sessions.push(this.currentSession); | |
| this.logActivity('New session started'); | |
| } | |
| updateDeviceInfo() { | |
| const userAgent = navigator.userAgent; | |
| let os = 'Unknown'; | |
| let browser = 'Unknown'; | |
| // Detect OS | |
| if (userAgent.match(/android/i)) { | |
| os = 'Android'; | |
| } else if (userAgent.match(/iphone|ipad|ipod/i)) { | |
| os = 'iOS'; | |
| } else if (userAgent.match(/windows/i)) { | |
| os = 'Windows'; | |
| } else if (userAgent.match(/mac/i)) { | |
| os = 'MacOS'; | |
| } else if (userAgent.match(/linux/i)) { | |
| os = 'Linux'; | |
| } | |
| // Detect Browser | |
| if (userAgent.match(/edg/i)) { | |
| browser = 'Edge'; | |
| } else if (userAgent.match(/opr/i)) { | |
| browser = 'Opera'; | |
| } else if (userAgent.match(/chrome/i)) { | |
| browser = 'Chrome'; | |
| } else if (userAgent.match(/firefox/i)) { | |
| browser = 'Firefox'; | |
| } else if (userAgent.match(/safari/i)) { | |
| browser = 'Safari'; | |
| } | |
| this.deviceInfo = { | |
| os, | |
| browser, | |
| screen: `${window.screen.width}x${window.screen.height}`, | |
| connection: navigator.connection ? navigator.connection.effectiveType : 'Unknown' | |
| }; | |
| if (this.currentSession) { | |
| this.currentSession.deviceInfo = {...this.deviceInfo}; | |
| } | |
| } | |
| initBatteryMonitoring() { | |
| if ('getBattery' in navigator) { | |
| navigator.getBattery().then(battery => { | |
| this.batteryInfo = { | |
| level: battery.level, | |
| charging: battery.charging, | |
| chargingTime: battery.chargingTime, | |
| dischargingTime: battery.dischargingTime | |
| }; | |
| // Update battery info when it changes | |
| battery.addEventListener('levelchange', () => { | |
| this.batteryInfo.level = battery.level; | |
| this.batteryInfo.charging = battery.charging; | |
| this.updateUI(); | |
| this.logActivity(`Battery level changed to ${Math.round(battery.level * 100)}%`); | |
| }); | |
| battery.addEventListener('chargingchange', () => { | |
| this.batteryInfo.charging = battery.charging; | |
| this.updateUI(); | |
| this.logActivity(`Battery charging state changed to ${battery.charging ? 'charging' : 'not charging'}`); | |
| }); | |
| this.updateUI(); | |
| }); | |
| } else { | |
| this.batteryInfo = { | |
| level: null, | |
| charging: null, | |
| chargingTime: null, | |
| dischargingTime: null | |
| }; | |
| } | |
| } | |
| initMotionMonitoring() { | |
| if ('DeviceMotionEvent' in window) { | |
| window.addEventListener('devicemotion', (event) => { | |
| const acceleration = event.acceleration; | |
| const accelerationIncludingGravity = event.accelerationIncludingGravity; | |
| const rotationRate = event.rotationRate; | |
| const interval = event.interval; | |
| this.motionData = { | |
| acceleration, | |
| accelerationIncludingGravity, | |
| rotationRate, | |
| interval, | |
| lastUpdated: new Date() | |
| }; | |
| // Simple motion detection | |
| const motionDetected = accelerationIncludingGravity && | |
| (Math.abs(accelerationIncludingGravity.x) > 1.5 || | |
| Math.abs(accelerationIncludingGravity.y) > 1.5 || | |
| Math.abs(accelerationIncludingGravity.z) > 1.5); | |
| if (motionDetected) { | |
| this.logActivity('Significant motion detected'); | |
| } | |
| this.updateUI(); | |
| }); | |
| } else { | |
| this.motionData = { | |
| error: 'Device motion not supported' | |
| }; | |
| } | |
| } | |
| initLocationMonitoring() { | |
| if ('geolocation' in navigator) { | |
| const options = { | |
| enableHighAccuracy: true, | |
| timeout: 5000, | |
| maximumAge: 0 | |
| }; | |
| const success = (position) => { | |
| this.locationData = { | |
| latitude: position.coords.latitude, | |
| longitude: position.coords.longitude, | |
| accuracy: position.coords.accuracy, | |
| altitude: position.coords.altitude, | |
| altitudeAccuracy: position.coords.altitudeAccuracy, | |
| heading: position.coords.heading, | |
| speed: position.coords.speed, | |
| timestamp: position.timestamp | |
| }; | |
| if (this.currentSession) { | |
| this.currentSession.locationData.push({...this.locationData}); | |
| } | |
| this.updateUI(); | |
| this.logActivity('Location updated'); | |
| }; | |
| const error = (err) => { | |
| this.locationData = { | |
| error: err.message | |
| }; | |
| this.logActivity(`Location error: ${err.message}`); | |
| }; | |
| // Get initial position | |
| navigator.geolocation.getCurrentPosition(success, error, options); | |
| // Watch for position changes | |
| this.locationWatchId = navigator.geolocation.watchPosition(success, error, options); | |
| } else { | |
| this.locationData = { | |
| error: 'Geolocation not supported' | |
| }; | |
| } | |
| } | |
| logActivity(message) { | |
| const timestamp = new Date(); | |
| this.activityLog.unshift({ | |
| timestamp, | |
| message | |
| }); | |
| // Keep only the last 50 activities | |
| if (this.activityLog.length > 50) { | |
| this.activityLog.pop(); | |
| } | |
| this.updateActivityLogUI(); | |
| } | |
| updateUI() { | |
| // Update screen time | |
| this.updateScreenTime(); | |
| // Update session count | |
| document.getElementById('sessionCount').textContent = this.sessions.length; | |
| // Update battery info | |
| if (this.batteryInfo.level !== null) { | |
| const batteryPercent = Math.round(this.batteryInfo.level * 100); | |
| document.getElementById('batteryPercent').textContent = `${batteryPercent}%`; | |
| document.getElementById('batteryLevel').style.width = `${batteryPercent}%`; | |
| // Change color based on battery level | |
| const batteryGauge = document.getElementById('batteryGauge'); | |
| batteryGauge.className = ''; | |
| if (batteryPercent > 60) { | |
| batteryGauge.classList.add('text-green-500'); | |
| } else if (batteryPercent > 20) { | |
| batteryGauge.classList.add('text-yellow-500'); | |
| } else { | |
| batteryGauge.classList.add('text-red-500'); | |
| } | |
| } | |
| // Update motion status | |
| if (this.motionData.accelerationIncludingGravity) { | |
| const motionDetected = Math.abs(this.motionData.accelerationIncludingGravity.x) > 1.5 || | |
| Math.abs(this.motionData.accelerationIncludingGravity.y) > 1.5 || | |
| Math.abs(this.motionData.accelerationIncludingGravity.z) > 1.5; | |
| document.getElementById('motionStatus').textContent = motionDetected ? 'Active' : 'Idle'; | |
| document.getElementById('motionStatus').className = motionDetected ? | |
| 'text-2xl font-bold text-green-500' : 'text-2xl font-bold text-gray-500'; | |
| } | |
| // Update device details | |
| document.getElementById('deviceOS').textContent = this.deviceInfo.os; | |
| document.getElementById('deviceBrowser').textContent = this.deviceInfo.browser; | |
| document.getElementById('deviceScreen').textContent = this.deviceInfo.screen; | |
| document.getElementById('deviceConnection').textContent = this.deviceInfo.connection; | |
| // Update session details | |
| if (this.currentSession) { | |
| document.getElementById('sessionStart').textContent = this.currentSession.start.toLocaleTimeString(); | |
| // Calculate current session duration | |
| let totalDuration = 0; | |
| for (const interval of this.currentSession.intervals) { | |
| const end = interval.end || new Date(); | |
| totalDuration += (end - interval.start); | |
| } | |
| const durationMinutes = Math.floor(totalDuration / 60000); | |
| const durationSeconds = Math.floor((totalDuration % 60000) / 1000); | |
| document.getElementById('sessionDuration').textContent = `${durationMinutes}m ${durationSeconds}s`; | |
| document.getElementById('sessionVisibility').textContent = | |
| document.visibilityState === 'visible' ? 'Visible' : 'Hidden'; | |
| if (this.locationData.latitude) { | |
| document.getElementById('sessionLocation').textContent = | |
| `${this.locationData.latitude.toFixed(4)}, ${this.locationData.longitude.toFixed(4)}`; | |
| } else { | |
| document.getElementById('sessionLocation').textContent = 'Not available'; | |
| } | |
| } | |
| // Update charts | |
| this.updateCharts(); | |
| } | |
| updateScreenTime() { | |
| if (this.sessions.length === 0) { | |
| document.getElementById('screenTime').textContent = '0h 0m'; | |
| return; | |
| } | |
| let totalScreenTime = 0; | |
| for (const session of this.sessions) { | |
| for (const interval of session.intervals) { | |
| const end = interval.end || new Date(); | |
| totalScreenTime += (end - interval.start); | |
| } | |
| } | |
| const hours = Math.floor(totalScreenTime / 3600000); | |
| const minutes = Math.floor((totalScreenTime % 3600000) / 60000); | |
| document.getElementById('screenTime').textContent = `${hours}h ${minutes}m`; | |
| } | |
| updateActivityLogUI() { | |
| const activityLogElement = document.getElementById('activityLog'); | |
| if (this.activityLog.length === 0) { | |
| activityLogElement.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-4">No activity recorded yet</p>'; | |
| return; | |
| } | |
| let html = ''; | |
| for (const activity of this.activityLog) { | |
| html += ` | |
| <div class="flex items-start py-2"> | |
| <div class="flex-shrink-0 h-2 w-2 rounded-full bg-blue-500 mt-1 mr-2"></div> | |
| <div class="flex-1 min-w-0"> | |
| <p class="text-sm font-medium">${activity.message}</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">${activity.timestamp.toLocaleTimeString()}</p> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| activityLogElement.innerHTML = html; | |
| } | |
| updateCharts() { | |
| // Screen Time Distribution Chart (Pie) | |
| this.updateScreenTimeChart(); | |
| // Activity Timeline Chart (Line) | |
| this.updateActivityTimelineChart(); | |
| } | |
| updateScreenTimeChart() { | |
| const ctx = document.getElementById('screenTimeChart').getContext('2d'); | |
| // Group by day for the pie chart | |
| const days = {}; | |
| const now = new Date(); | |
| for (const session of this.sessions) { | |
| for (const interval of session.intervals) { | |
| const end = interval.end || now; | |
| const duration = (end - interval.start); | |
| const dateKey = interval.start.toDateString(); | |
| if (!days[dateKey]) { | |
| days[dateKey] = 0; | |
| } | |
| days[dateKey] += duration; | |
| } | |
| } | |
| const labels = Object.keys(days); | |
| const data = labels.map(date => { | |
| // Convert milliseconds to hours | |
| return (days[date] / 3600000).toFixed(2); | |
| }); | |
| if (!this.charts.screenTimeChart) { | |
| this.charts.screenTimeChart = new Chart(ctx, { | |
| type: 'pie', | |
| data: { | |
| labels: labels, | |
| datasets: [{ | |
| data: data, | |
| backgroundColor: [ | |
| '#3B82F6', | |
| '#10B981', | |
| '#F59E0B', | |
| '#EF4444', | |
| '#8B5CF6', | |
| '#EC4899', | |
| '#14B8A6' | |
| ], | |
| borderWidth: 1 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { | |
| position: 'right', | |
| labels: { | |
| color: this.theme === 'dark' ? '#E5E7EB' : '#374151' | |
| } | |
| }, | |
| tooltip: { | |
| callbacks: { | |
| label: function(context) { | |
| return `${context.label}: ${context.raw} hours`; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } else { | |
| this.charts.screenTimeChart.data.labels = labels; | |
| this.charts.screenTimeChart.data.datasets[0].data = data; | |
| this.charts.screenTimeChart.update(); | |
| } | |
| } | |
| updateActivityTimelineChart() { | |
| const ctx = document.getElementById('activityTimeline').getContext('2d'); | |
| // Prepare data for the last 24 hours | |
| const now = new Date(); | |
| const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); | |
| // Create time slots (every 30 minutes) | |
| const timeSlots = []; | |
| for (let i = 0; i < 48; i++) { | |
| const time = new Date(oneDayAgo.getTime() + i * 30 * 60 * 1000); | |
| timeSlots.push({ | |
| time, | |
| active: false, | |
| duration: 0 | |
| }); | |
| } | |
| // Mark active periods | |
| for (const session of this.sessions) { | |
| for (const interval of session.intervals) { | |
| if (!interval.end) continue; | |
| const start = interval.start; | |
| const end = interval.end; | |
| for (const slot of timeSlots) { | |
| const slotStart = slot.time; | |
| const slotEnd = new Date(slotStart.getTime() + 30 * 60 * 1000); | |
| // Check if interval overlaps with this time slot | |
| if (start < slotEnd && end > slotStart) { | |
| const overlapStart = new Date(Math.max(start, slotStart)); | |
| const overlapEnd = new Date(Math.min(end, slotEnd)); | |
| const overlapDuration = overlapEnd - overlapStart; | |
| slot.active = true; | |
| slot.duration += overlapDuration; | |
| } | |
| } | |
| } | |
| } | |
| const labels = timeSlots.map(slot => slot.time.getHours() + ':' + (slot.time.getMinutes() < 10 ? '0' : '') + slot.time.getMinutes()); | |
| const data = timeSlots.map(slot => slot.active ? (slot.duration / (30 * 60 * 1000)) * 100 : 0); | |
| if (!this.charts.activityTimelineChart) { | |
| this.charts.activityTimelineChart = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels: labels, | |
| datasets: [{ | |
| label: 'Activity Level', | |
| data: data, | |
| backgroundColor: 'rgba(59, 130, 246, 0.2)', | |
| borderColor: 'rgba(59, 130, 246, 1)', | |
| borderWidth: 1, | |
| tension: 0.4, | |
| fill: true | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| y: { | |
| beginAtZero: true, | |
| max: 100, | |
| ticks: { | |
| callback: function(value) { | |
| return value + '%'; | |
| }, | |
| color: this.theme === 'dark' ? '#E5E7EB' : '#374151' | |
| }, | |
| grid: { | |
| color: this.theme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)' | |
| } | |
| }, | |
| x: { | |
| ticks: { | |
| maxRotation: 0, | |
| autoSkip: true, | |
| maxTicksLimit: 12, | |
| color: this.theme === 'dark' ? '#E5E7EB' : '#374151' | |
| }, | |
| grid: { | |
| color: this.theme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)' | |
| } | |
| } | |
| }, | |
| plugins: { | |
| legend: { | |
| display: false | |
| }, | |
| tooltip: { | |
| callbacks: { | |
| label: function(context) { | |
| return `Activity: ${Math.round(context.raw)}%`; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } else { | |
| this.charts.activityTimelineChart.data.labels = labels; | |
| this.charts.activityTimelineChart.data.datasets[0].data = data; | |
| this.charts.activityTimelineChart.options.scales.y.grid.color = this.theme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; | |
| this.charts.activityTimelineChart.options.scales.x.grid.color = this.theme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; | |
| this.charts.activityTimelineChart.update(); | |
| } | |
| } | |
| loadData() { | |
| // Load from localStorage | |
| const savedData = localStorage.getItem('vibeMonitorData'); | |
| if (savedData) { | |
| try { | |
| const data = JSON.parse(savedData); | |
| this.sessions = data.sessions ? data.sessions.map(session => { | |
| return { | |
| ...session, | |
| start: new Date(session.start), | |
| intervals: session.intervals.map(interval => { | |
| return { | |
| ...interval, | |
| start: new Date(interval.start), | |
| end: interval.end ? new Date(interval.end) : null | |
| }; | |
| }) | |
| }; | |
| }) : []; | |
| this.activityLog = data.activityLog ? data.activityLog.map(log => { | |
| return { | |
| ...log, | |
| timestamp: new Date(log.timestamp) | |
| }; | |
| }) : []; | |
| this.logActivity('Loaded saved data from storage'); | |
| } catch (e) { | |
| console.error('Failed to parse saved data', e); | |
| } | |
| } | |
| // Set current session to the last one if it's not ended | |
| if (this.sessions.length > 0) { | |
| const lastSession = this.sessions[this.sessions.length - 1]; | |
| if (lastSession.intervals.some(interval => interval.end === null)) { | |
| this.currentSession = lastSession; | |
| } | |
| } | |
| } | |
| saveData() { | |
| if (!this.trackingEnabled) return; | |
| const data = { | |
| sessions: this.sessions, | |
| activityLog: this.activityLog | |
| }; | |
| localStorage.setItem('vibeMonitorData', JSON.stringify(data)); | |
| } | |
| exportData(format) { | |
| const data = { | |
| sessions: this.sessions, | |
| activityLog: this.activityLog, | |
| deviceInfo: this.deviceInfo, | |
| batteryInfo: this.batteryInfo, | |
| motionData: this.motionData, | |
| locationData: this.locationData, | |
| exportedAt: new Date().toISOString() | |
| }; | |
| let content, mimeType, extension; | |
| if (format === 'json') { | |
| content = JSON.stringify(data, null, 2); | |
| mimeType = 'application/json'; | |
| extension = 'json'; | |
| } else if (format === 'csv') { | |
| // Convert sessions to CSV | |
| let csvContent = "Session Start,Session Duration (ms),Interval Count,Location Count\n"; | |
| for (const session of data.sessions) { | |
| const duration = session.intervals.reduce((total, interval) => { | |
| const end = interval.end || new Date(); | |
| return total + (end - interval.start); | |
| }, 0); | |
| csvContent += `"${session.start.toISOString()}",${duration},${session.intervals.length},${session.locationData ? session.locationData.length : 0}\n`; | |
| } | |
| // Add activity log | |
| csvContent += "\nActivity Log\nTimestamp,Message\n"; | |
| for (const log of data.activityLog) { | |
| csvContent += `"${log.timestamp.toISOString()}","${log.message.replace(/"/g, '""')}"\n`; | |
| } | |
| content = csvContent; | |
| mimeType = 'text/csv'; | |
| extension = 'csv'; | |
| } | |
| // Create download link | |
| const blob = new Blob([content], { type: mimeType }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `vibe-monitor-export-${new Date().toISOString().slice(0, 10)}.${extension}`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| this.logActivity(`Exported data as ${format.toUpperCase()}`); | |
| document.getElementById('exportModal').classList.add('hidden'); | |
| } | |
| setTheme(theme) { | |
| this.theme = theme; | |
| document.documentElement.classList.toggle('dark', theme === 'dark'); | |
| localStorage.setItem('theme', theme); | |
| // Update charts if they exist | |
| if (this.charts.screenTimeChart) { | |
| this.charts.screenTimeChart.options.plugins.legend.labels.color = theme === 'dark' ? '#E5E7EB' : '#374151'; | |
| this.charts.screenTimeChart.update(); | |
| } | |
| if (this.charts.activityTimelineChart) { | |
| this.charts.activityTimelineChart.options.scales.y.grid.color = theme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; | |
| this.charts.activityTimelineChart.options.scales.x.grid.color = theme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; | |
| this.charts.activityTimelineChart.update(); | |
| } | |
| } | |
| toggleTheme() { | |
| this.setTheme(this.theme === 'light' ? 'dark' : 'light'); | |
| } | |
| } | |
| // Register service worker for PWA functionality | |
| if ('serviceWorker' in navigator) { | |
| window.addEventListener('load', () => { | |
| navigator.serviceWorker.register('/sw.js').then(registration => { | |
| console.log('ServiceWorker registration successful'); | |
| }).catch(err => { | |
| console.log('ServiceWorker registration failed: ', err); | |
| }); | |
| }); | |
| } | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=Doobdeedoo/vieb-monitor" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |