feat: Implement idle user status and enhanced activity tracking
- Add idle status type and timeout mechanism - Track user activity across mouse, keyboard, and click events - Implement dynamic status updates based on user interaction - Enhance visibility change and activity tracking logic - Add idle timeout and reset functionality
- commit
- 21b8492835b41da84f18db840db28f93458f1d20
- author
- Kit · · 2025-02-15 11:30 UTC
- parent
- e1cfa3d
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
1 file · +57 -11
M src/routes/+layout.svelte +57 -11
| @@ -9,23 +9,25 @@ | |||
| 9 | 9 | let statusInterval = $state<number | undefined>(undefined); | |
| 10 | 10 | let lastUserUpdate = $state<string | null>(null); | |
| 11 | 11 | let updateInProgress = $state(false); | |
| 12 | + | let idleTimeout = $state<number | undefined>(undefined); | |
| 12 | 13 | ||
| 13 | 14 | // New throttle state variables | |
| 14 | 15 | let lastSentStatus = $state<string | null>(null); | |
| 15 | 16 | let lastStatusSentAt = $state<number>(0); | |
| 16 | - | const STATUS_UPDATE_INTERVAL = 15000; // Changed to 15 seconds | |
| 17 | - | const THROTTLE_TIME_MS = 10000; // Changed to 10 seconds to prevent edge case overlaps | |
| 18 | - | const FORCE_UPDATE_INTERVAL = 5 * 60000; // Force update every 5 minutes regardless of status change | |
| 17 | + | let lastUserActivity = $state<number>(Date.now()); | |
| 18 | + | const STATUS_UPDATE_INTERVAL = 15000; // 15 seconds | |
| 19 | + | const THROTTLE_TIME_MS = 10000; // 10 seconds | |
| 20 | + | const FORCE_UPDATE_INTERVAL = 5 * 60000; // 5 minutes | |
| 21 | + | const IDLE_TIMEOUT = 5 * 60000; // 5 minutes of inactivity = idle | |
| 19 | 22 | ||
| 20 | - | async function updateUserStatus(status: 'online' | 'offline' | 'busy') { | |
| 23 | + | type UserStatus = 'online' | 'offline' | 'busy' | 'idle'; | |
| 24 | + | ||
| 25 | + | async function updateUserStatus(status: UserStatus) { | |
| 21 | 26 | if (!data.user?.id || updateInProgress) return; | |
| 22 | 27 | ||
| 23 | 28 | const now = Date.now(); | |
| 24 | 29 | const timeSinceLastUpdate = now - lastStatusSentAt; | |
| 25 | 30 | ||
| 26 | - | // Only update if: | |
| 27 | - | // 1. Status has changed OR | |
| 28 | - | // 2. We haven't updated in FORCE_UPDATE_INTERVAL (5 minutes) | |
| 29 | 31 | if ( | |
| 30 | 32 | status === lastSentStatus && | |
| 31 | 33 | timeSinceLastUpdate < FORCE_UPDATE_INTERVAL && | |
| @@ -53,7 +55,6 @@ | |||
| 53 | 55 | }); | |
| 54 | 56 | ||
| 55 | 57 | try { | |
| 56 | - | // Add a small delay to ensure cookie is set after login | |
| 57 | 58 | if (status === 'online' && !document.cookie.includes('session=')) { | |
| 58 | 59 | console.debug('Waiting for session cookie before status update...'); | |
| 59 | 60 | await new Promise(resolve => setTimeout(resolve, 500)); | |
| @@ -71,6 +72,8 @@ | |||
| 71 | 72 | if (!response.ok) { | |
| 72 | 73 | console.error('Failed to update user status:', await response.json()); | |
| 73 | 74 | } else { | |
| 75 | + | lastSentStatus = status; | |
| 76 | + | lastStatusSentAt = now; | |
| 74 | 77 | console.debug('Status update successful:', { | |
| 75 | 78 | userId: data.user.id, | |
| 76 | 79 | status, | |
| @@ -84,12 +87,34 @@ | |||
| 84 | 87 | } | |
| 85 | 88 | } | |
| 86 | 89 | ||
| 90 | + | function resetIdleTimer() { | |
| 91 | + | if (idleTimeout) { | |
| 92 | + | window.clearTimeout(idleTimeout); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | lastUserActivity = Date.now(); | |
| 96 | + | ||
| 97 | + | if (lastSentStatus === 'idle') { | |
| 98 | + | updateUserStatus('online'); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | idleTimeout = window.setTimeout(() => { | |
| 102 | + | if (isVisible && Date.now() - lastUserActivity >= IDLE_TIMEOUT) { | |
| 103 | + | updateUserStatus('idle'); | |
| 104 | + | } | |
| 105 | + | }, IDLE_TIMEOUT); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | function handleUserActivity() { | |
| 109 | + | resetIdleTimer(); | |
| 110 | + | } | |
| 111 | + | ||
| 87 | 112 | function startStatusTicker() { | |
| 88 | 113 | if (statusInterval) return; | |
| 89 | 114 | ||
| 90 | 115 | console.debug('Starting status ticker'); | |
| 91 | 116 | const tickHandler = () => { | |
| 92 | - | if (isVisible) { | |
| 117 | + | if (isVisible && lastSentStatus !== 'idle') { | |
| 93 | 118 | updateUserStatus('online'); | |
| 94 | 119 | } | |
| 95 | 120 | }; | |
| @@ -107,11 +132,13 @@ | |||
| 107 | 132 | function handleVisibilityChange() { | |
| 108 | 133 | if (!browser) return; | |
| 109 | 134 | isVisible = document.visibilityState === 'visible'; | |
| 110 | - | updateUserStatus(isVisible ? 'online' : 'busy'); | |
| 111 | 135 | ||
| 112 | 136 | if (isVisible) { | |
| 137 | + | resetIdleTimer(); | |
| 138 | + | updateUserStatus('online'); | |
| 113 | 139 | startStatusTicker(); | |
| 114 | 140 | } else { | |
| 141 | + | updateUserStatus('busy'); | |
| 115 | 142 | stopStatusTicker(); | |
| 116 | 143 | } | |
| 117 | 144 | } | |
| @@ -120,6 +147,12 @@ | |||
| 120 | 147 | if (!browser) return; | |
| 121 | 148 | console.debug('Running cleanup - Setting user status to offline'); | |
| 122 | 149 | document.removeEventListener('visibilitychange', handleVisibilityChange); | |
| 150 | + | window.removeEventListener('mousemove', handleUserActivity); | |
| 151 | + | window.removeEventListener('keydown', handleUserActivity); | |
| 152 | + | window.removeEventListener('click', handleUserActivity); | |
| 153 | + | if (idleTimeout) { | |
| 154 | + | window.clearTimeout(idleTimeout); | |
| 155 | + | } | |
| 123 | 156 | stopStatusTicker(); | |
| 124 | 157 | updateUserStatus('offline'); | |
| 125 | 158 | } | |
| @@ -129,14 +162,27 @@ | |||
| 129 | 162 | ||
| 130 | 163 | // Set initial online status | |
| 131 | 164 | updateUserStatus('online'); | |
| 165 | + | ||
| 166 | + | // Start monitoring for user activity | |
| 167 | + | window.addEventListener('mousemove', handleUserActivity); | |
| 168 | + | window.addEventListener('keydown', handleUserActivity); | |
| 169 | + | window.addEventListener('click', handleUserActivity); | |
| 170 | + | ||
| 171 | + | // Initialize idle timer | |
| 172 | + | resetIdleTimer(); | |
| 132 | 173 | ||
| 133 | - | // Start the status ticker (which will now only fire every minute) | |
| 174 | + | // Start the status ticker | |
| 134 | 175 | startStatusTicker(); | |
| 135 | 176 | ||
| 136 | 177 | // Add visibility change listener | |
| 137 | 178 | document.addEventListener('visibilitychange', handleVisibilityChange); | |
| 179 | + | ||
| 180 | + | // Add beforeunload listener for cleanup | |
| 181 | + | window.addEventListener('beforeunload', cleanup); | |
| 138 | 182 | ||
| 183 | + | // Cleanup on component destroy | |
| 139 | 184 | return () => { | |
| 185 | + | cleanup(); | |
| 140 | 186 | window.removeEventListener('beforeunload', cleanup); | |
| 141 | 187 | }; | |
| 142 | 188 | }); | |
| @@ -9,23 +9,25 @@ | |||||
| 9 | let statusInterval = $state<number | undefined>(undefined); | 9 | let statusInterval = $state<number | undefined>(undefined); | ||
| 10 | let lastUserUpdate = $state<string | null>(null); | 10 | let lastUserUpdate = $state<string | null>(null); | ||
| 11 | let updateInProgress = $state(false); | 11 | let updateInProgress = $state(false); | ||
| 12 | + | let idleTimeout = $state<number | undefined>(undefined); | |||
| 12 | 13 | ||||
| 13 | // New throttle state variables | 14 | // New throttle state variables | ||
| 14 | let lastSentStatus = $state<string | null>(null); | 15 | let lastSentStatus = $state<string | null>(null); | ||
| 15 | let lastStatusSentAt = $state<number>(0); | 16 | let lastStatusSentAt = $state<number>(0); | ||
| 16 | - | const STATUS_UPDATE_INTERVAL = 15000; // Changed to 15 seconds | 17 | + | let lastUserActivity = $state<number>(Date.now()); |
| 17 | - | const THROTTLE_TIME_MS = 10000; // Changed to 10 seconds to prevent edge case overlaps | 18 | + | const STATUS_UPDATE_INTERVAL = 15000; // 15 seconds |
| 18 | - | const FORCE_UPDATE_INTERVAL = 5 * 60000; // Force update every 5 minutes regardless of status change | 19 | + | const THROTTLE_TIME_MS = 10000; // 10 seconds |
| 20 | + | const FORCE_UPDATE_INTERVAL = 5 * 60000; // 5 minutes | |||
| 21 | + | const IDLE_TIMEOUT = 5 * 60000; // 5 minutes of inactivity = idle | |||
| 19 | 22 | ||||
| 20 | - | async function updateUserStatus(status: 'online' | 'offline' | 'busy') { | 23 | + | type UserStatus = 'online' | 'offline' | 'busy' | 'idle'; |
| 24 | + | ||||
| 25 | + | async function updateUserStatus(status: UserStatus) { | |||
| 21 | if (!data.user?.id || updateInProgress) return; | 26 | if (!data.user?.id || updateInProgress) return; | ||
| 22 | 27 | ||||
| 23 | const now = Date.now(); | 28 | const now = Date.now(); | ||
| 24 | const timeSinceLastUpdate = now - lastStatusSentAt; | 29 | const timeSinceLastUpdate = now - lastStatusSentAt; | ||
| 25 | 30 | ||||
| 26 | - | // Only update if: | |||
| 27 | - | // 1. Status has changed OR | |||
| 28 | - | // 2. We haven't updated in FORCE_UPDATE_INTERVAL (5 minutes) | |||
| 29 | if ( | 31 | if ( | ||
| 30 | status === lastSentStatus && | 32 | status === lastSentStatus && | ||
| 31 | timeSinceLastUpdate < FORCE_UPDATE_INTERVAL && | 33 | timeSinceLastUpdate < FORCE_UPDATE_INTERVAL && | ||
| @@ -53,7 +55,6 @@ | |||||
| 53 | }); | 55 | }); | ||
| 54 | 56 | ||||
| 55 | try { | 57 | try { | ||
| 56 | - | // Add a small delay to ensure cookie is set after login | |||
| 57 | if (status === 'online' && !document.cookie.includes('session=')) { | 58 | if (status === 'online' && !document.cookie.includes('session=')) { | ||
| 58 | console.debug('Waiting for session cookie before status update...'); | 59 | console.debug('Waiting for session cookie before status update...'); | ||
| 59 | await new Promise(resolve => setTimeout(resolve, 500)); | 60 | await new Promise(resolve => setTimeout(resolve, 500)); | ||
| @@ -71,6 +72,8 @@ | |||||
| 71 | if (!response.ok) { | 72 | if (!response.ok) { | ||
| 72 | console.error('Failed to update user status:', await response.json()); | 73 | console.error('Failed to update user status:', await response.json()); | ||
| 73 | } else { | 74 | } else { | ||
| 75 | + | lastSentStatus = status; | |||
| 76 | + | lastStatusSentAt = now; | |||
| 74 | console.debug('Status update successful:', { | 77 | console.debug('Status update successful:', { | ||
| 75 | userId: data.user.id, | 78 | userId: data.user.id, | ||
| 76 | status, | 79 | status, | ||
| @@ -84,12 +87,34 @@ | |||||
| 84 | } | 87 | } | ||
| 85 | } | 88 | } | ||
| 86 | 89 | ||||
| 90 | + | function resetIdleTimer() { | |||
| 91 | + | if (idleTimeout) { | |||
| 92 | + | window.clearTimeout(idleTimeout); | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | lastUserActivity = Date.now(); | |||
| 96 | + | ||||
| 97 | + | if (lastSentStatus === 'idle') { | |||
| 98 | + | updateUserStatus('online'); | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | idleTimeout = window.setTimeout(() => { | |||
| 102 | + | if (isVisible && Date.now() - lastUserActivity >= IDLE_TIMEOUT) { | |||
| 103 | + | updateUserStatus('idle'); | |||
| 104 | + | } | |||
| 105 | + | }, IDLE_TIMEOUT); | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | function handleUserActivity() { | |||
| 109 | + | resetIdleTimer(); | |||
| 110 | + | } | |||
| 111 | + | ||||
| 87 | function startStatusTicker() { | 112 | function startStatusTicker() { | ||
| 88 | if (statusInterval) return; | 113 | if (statusInterval) return; | ||
| 89 | 114 | ||||
| 90 | console.debug('Starting status ticker'); | 115 | console.debug('Starting status ticker'); | ||
| 91 | const tickHandler = () => { | 116 | const tickHandler = () => { | ||
| 92 | - | if (isVisible) { | 117 | + | if (isVisible && lastSentStatus !== 'idle') { |
| 93 | updateUserStatus('online'); | 118 | updateUserStatus('online'); | ||
| 94 | } | 119 | } | ||
| 95 | }; | 120 | }; | ||
| @@ -107,11 +132,13 @@ | |||||
| 107 | function handleVisibilityChange() { | 132 | function handleVisibilityChange() { | ||
| 108 | if (!browser) return; | 133 | if (!browser) return; | ||
| 109 | isVisible = document.visibilityState === 'visible'; | 134 | isVisible = document.visibilityState === 'visible'; | ||
| 110 | - | updateUserStatus(isVisible ? 'online' : 'busy'); | |||
| 111 | 135 | ||||
| 112 | if (isVisible) { | 136 | if (isVisible) { | ||
| 137 | + | resetIdleTimer(); | |||
| 138 | + | updateUserStatus('online'); | |||
| 113 | startStatusTicker(); | 139 | startStatusTicker(); | ||
| 114 | } else { | 140 | } else { | ||
| 141 | + | updateUserStatus('busy'); | |||
| 115 | stopStatusTicker(); | 142 | stopStatusTicker(); | ||
| 116 | } | 143 | } | ||
| 117 | } | 144 | } | ||
| @@ -120,6 +147,12 @@ | |||||
| 120 | if (!browser) return; | 147 | if (!browser) return; | ||
| 121 | console.debug('Running cleanup - Setting user status to offline'); | 148 | console.debug('Running cleanup - Setting user status to offline'); | ||
| 122 | document.removeEventListener('visibilitychange', handleVisibilityChange); | 149 | document.removeEventListener('visibilitychange', handleVisibilityChange); | ||
| 150 | + | window.removeEventListener('mousemove', handleUserActivity); | |||
| 151 | + | window.removeEventListener('keydown', handleUserActivity); | |||
| 152 | + | window.removeEventListener('click', handleUserActivity); | |||
| 153 | + | if (idleTimeout) { | |||
| 154 | + | window.clearTimeout(idleTimeout); | |||
| 155 | + | } | |||
| 123 | stopStatusTicker(); | 156 | stopStatusTicker(); | ||
| 124 | updateUserStatus('offline'); | 157 | updateUserStatus('offline'); | ||
| 125 | } | 158 | } | ||
| @@ -129,14 +162,27 @@ | |||||
| 129 | 162 | ||||
| 130 | // Set initial online status | 163 | // Set initial online status | ||
| 131 | updateUserStatus('online'); | 164 | updateUserStatus('online'); | ||
| 165 | + | ||||
| 166 | + | // Start monitoring for user activity | |||
| 167 | + | window.addEventListener('mousemove', handleUserActivity); | |||
| 168 | + | window.addEventListener('keydown', handleUserActivity); | |||
| 169 | + | window.addEventListener('click', handleUserActivity); | |||
| 170 | + | ||||
| 171 | + | // Initialize idle timer | |||
| 172 | + | resetIdleTimer(); | |||
| 132 | 173 | ||||
| 133 | - | // Start the status ticker (which will now only fire every minute) | 174 | + | // Start the status ticker |
| 134 | startStatusTicker(); | 175 | startStatusTicker(); | ||
| 135 | 176 | ||||
| 136 | // Add visibility change listener | 177 | // Add visibility change listener | ||
| 137 | document.addEventListener('visibilitychange', handleVisibilityChange); | 178 | document.addEventListener('visibilitychange', handleVisibilityChange); | ||
| 179 | + | ||||
| 180 | + | // Add beforeunload listener for cleanup | |||
| 181 | + | window.addEventListener('beforeunload', cleanup); | |||
| 138 | 182 | ||||
| 183 | + | // Cleanup on component destroy | |||
| 139 | return () => { | 184 | return () => { | ||
| 185 | + | cleanup(); | |||
| 140 | window.removeEventListener('beforeunload', cleanup); | 186 | window.removeEventListener('beforeunload', cleanup); | ||
| 141 | }; | 187 | }; | ||
| 142 | }); | 188 | }); | ||