detached 21b8492

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 UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
1 file · +57 -11
M src/routes/+layout.svelte +57 -11
@@ -9,23 +9,25 @@
99 let statusInterval = $state<number | undefined>(undefined);
1010 let lastUserUpdate = $state<string | null>(null);
1111 let updateInProgress = $state(false);
12+ let idleTimeout = $state<number | undefined>(undefined);
1213
1314 // New throttle state variables
1415 let lastSentStatus = $state<string | null>(null);
1516 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
1922
20- async function updateUserStatus(status: 'online' | 'offline' | 'busy') {
23+ type UserStatus = 'online' | 'offline' | 'busy' | 'idle';
24+
25+ async function updateUserStatus(status: UserStatus) {
2126 if (!data.user?.id || updateInProgress) return;
2227
2328 const now = Date.now();
2429 const timeSinceLastUpdate = now - lastStatusSentAt;
2530
26- // Only update if:
27- // 1. Status has changed OR
28- // 2. We haven't updated in FORCE_UPDATE_INTERVAL (5 minutes)
2931 if (
3032 status === lastSentStatus &&
3133 timeSinceLastUpdate < FORCE_UPDATE_INTERVAL &&
@@ -53,7 +55,6 @@
5355 });
5456
5557 try {
56- // Add a small delay to ensure cookie is set after login
5758 if (status === 'online' && !document.cookie.includes('session=')) {
5859 console.debug('Waiting for session cookie before status update...');
5960 await new Promise(resolve => setTimeout(resolve, 500));
@@ -71,6 +72,8 @@
7172 if (!response.ok) {
7273 console.error('Failed to update user status:', await response.json());
7374 } else {
75+ lastSentStatus = status;
76+ lastStatusSentAt = now;
7477 console.debug('Status update successful:', {
7578 userId: data.user.id,
7679 status,
@@ -84,12 +87,34 @@
8487 }
8588 }
8689
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+
87112 function startStatusTicker() {
88113 if (statusInterval) return;
89114
90115 console.debug('Starting status ticker');
91116 const tickHandler = () => {
92- if (isVisible) {
117+ if (isVisible && lastSentStatus !== 'idle') {
93118 updateUserStatus('online');
94119 }
95120 };
@@ -107,11 +132,13 @@
107132 function handleVisibilityChange() {
108133 if (!browser) return;
109134 isVisible = document.visibilityState === 'visible';
110- updateUserStatus(isVisible ? 'online' : 'busy');
111135
112136 if (isVisible) {
137+ resetIdleTimer();
138+ updateUserStatus('online');
113139 startStatusTicker();
114140 } else {
141+ updateUserStatus('busy');
115142 stopStatusTicker();
116143 }
117144 }
@@ -120,6 +147,12 @@
120147 if (!browser) return;
121148 console.debug('Running cleanup - Setting user status to offline');
122149 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+ }
123156 stopStatusTicker();
124157 updateUserStatus('offline');
125158 }
@@ -129,14 +162,27 @@
129162
130163 // Set initial online status
131164 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();
132173
133- // Start the status ticker (which will now only fire every minute)
174+ // Start the status ticker
134175 startStatusTicker();
135176
136177 // Add visibility change listener
137178 document.addEventListener('visibilitychange', handleVisibilityChange);
179+
180+ // Add beforeunload listener for cleanup
181+ window.addEventListener('beforeunload', cleanup);
138182
183+ // Cleanup on component destroy
139184 return () => {
185+ cleanup();
140186 window.removeEventListener('beforeunload', cleanup);
141187 };
142188 });
@@ -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 variables14 // 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 seconds17+ let lastUserActivity = $state<number>(Date.now());
17- const THROTTLE_TIME_MS = 10000; // Changed to 10 seconds to prevent edge case overlaps18+ const STATUS_UPDATE_INTERVAL = 15000; // 15 seconds
18- const FORCE_UPDATE_INTERVAL = 5 * 60000; // Force update every 5 minutes regardless of status change19+ 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 status163 // 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 listener177 // 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 });

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close