Skip to content

Commit 690726e

Browse files
authored
Merge branch 'main' into feature/keyboard-shortcuts
2 parents f10bb48 + f764a2e commit 690726e

6 files changed

Lines changed: 397 additions & 4 deletions

File tree

frontend/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import React, { useSyncExternalStore, useCallback } from 'react';
2+
import { useTranslation } from '../i18n';
3+
4+
interface RefreshControlProps {
5+
isPolling: boolean;
6+
isPaused: boolean;
7+
pauseReason: 'hidden' | 'offline' | 'manual' | null;
8+
onPause: () => void;
9+
onResume: () => void;
10+
onRefresh: () => void;
11+
isRefetching?: boolean;
12+
lastUpdated?: Date | null;
13+
}
14+
15+
function subscribeToTime(callback: () => void): () => void {
16+
const interval = setInterval(callback, 30000);
17+
return () => clearInterval(interval);
18+
}
19+
20+
function useRelativeTime(date: Date | null | undefined, t: (key: string) => string): string {
21+
const getSnapshot = useCallback(() => {
22+
if (!date) return '';
23+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
24+
if (seconds < 60) return t('refresh.justNow');
25+
const minutes = Math.floor(seconds / 60);
26+
if (minutes === 1) return t('refresh.oneMinuteAgo');
27+
return `${minutes} ${t('refresh.minutesAgo')}`;
28+
}, [date, t]);
29+
30+
return useSyncExternalStore(subscribeToTime, getSnapshot, getSnapshot);
31+
}
32+
33+
const RefreshControl: React.FC<RefreshControlProps> = ({
34+
isPolling,
35+
isPaused,
36+
pauseReason,
37+
onPause,
38+
onResume,
39+
onRefresh,
40+
isRefetching = false,
41+
lastUpdated,
42+
}) => {
43+
const { t } = useTranslation();
44+
const lastUpdatedText = useRelativeTime(lastUpdated, t);
45+
46+
const getPauseReasonText = (): string => {
47+
switch (pauseReason) {
48+
case 'hidden':
49+
return t('refresh.pausedHidden');
50+
case 'offline':
51+
return t('refresh.pausedOffline');
52+
case 'manual':
53+
return t('refresh.pausedManual');
54+
default:
55+
return '';
56+
}
57+
};
58+
59+
return (
60+
<div
61+
style={{
62+
display: 'flex',
63+
alignItems: 'center',
64+
gap: '12px',
65+
padding: '8px 12px',
66+
background: 'var(--bg-card)',
67+
borderRadius: '8px',
68+
fontSize: 'var(--text-sm)',
69+
}}
70+
>
71+
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
72+
<span
73+
style={{
74+
width: '8px',
75+
height: '8px',
76+
borderRadius: '50%',
77+
background: isPolling
78+
? 'var(--accent-green, #22c55e)'
79+
: isPaused
80+
? 'var(--accent-yellow, #eab308)'
81+
: 'var(--text-secondary)',
82+
}}
83+
/>
84+
<span style={{ color: 'var(--text-secondary)' }}>
85+
{isPolling
86+
? t('refresh.live')
87+
: isPaused
88+
? getPauseReasonText()
89+
: t('refresh.stopped')}
90+
</span>
91+
</div>
92+
93+
{lastUpdatedText && (
94+
<span style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
95+
{lastUpdatedText}
96+
</span>
97+
)}
98+
99+
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto' }}>
100+
{isPolling ? (
101+
<button
102+
onClick={onPause}
103+
style={{
104+
padding: '4px 12px',
105+
background: 'transparent',
106+
border: '1px solid var(--border-glass)',
107+
borderRadius: '6px',
108+
color: 'var(--text-primary)',
109+
cursor: 'pointer',
110+
fontSize: 'var(--text-sm)',
111+
}}
112+
>
113+
{t('refresh.pause')}
114+
</button>
115+
) : isPaused && pauseReason === 'manual' ? (
116+
<button
117+
onClick={onResume}
118+
style={{
119+
padding: '4px 12px',
120+
background: 'var(--accent-cyan)',
121+
border: 'none',
122+
borderRadius: '6px',
123+
color: '#000',
124+
cursor: 'pointer',
125+
fontSize: 'var(--text-sm)',
126+
fontWeight: 500,
127+
}}
128+
>
129+
{t('refresh.resume')}
130+
</button>
131+
) : null}
132+
133+
<button
134+
onClick={onRefresh}
135+
disabled={isRefetching}
136+
style={{
137+
padding: '4px 12px',
138+
background: 'transparent',
139+
border: '1px solid var(--border-glass)',
140+
borderRadius: '6px',
141+
color: 'var(--text-primary)',
142+
cursor: isRefetching ? 'not-allowed' : 'pointer',
143+
fontSize: 'var(--text-sm)',
144+
opacity: isRefetching ? 0.6 : 1,
145+
}}
146+
>
147+
{isRefetching ? t('refresh.refreshing') : t('refresh.refreshNow')}
148+
</button>
149+
</div>
150+
</div>
151+
);
152+
};
153+
154+
export default RefreshControl;

frontend/src/hooks/usePolling.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { useEffect, useState, useCallback, useRef } from 'react';
2+
3+
interface UsePollingOptions {
4+
interval: number;
5+
enabled?: boolean;
6+
pauseOnHidden?: boolean;
7+
pauseOnOffline?: boolean;
8+
}
9+
10+
interface UsePollingResult {
11+
isPolling: boolean;
12+
isPaused: boolean;
13+
pauseReason: 'hidden' | 'offline' | 'manual' | null;
14+
pause: () => void;
15+
resume: () => void;
16+
forceRefresh: () => void;
17+
}
18+
19+
export function usePolling(
20+
refetchFn: () => Promise<unknown>,
21+
options: UsePollingOptions
22+
): UsePollingResult {
23+
const {
24+
interval,
25+
enabled = true,
26+
pauseOnHidden = true,
27+
pauseOnOffline = true,
28+
} = options;
29+
30+
const [isPolling, setIsPolling] = useState(false);
31+
const [manualPause, setManualPause] = useState(false);
32+
const [isHidden, setIsHidden] = useState(
33+
typeof document !== 'undefined' && document.hidden
34+
);
35+
const [isOffline, setIsOffline] = useState(
36+
typeof navigator !== 'undefined' && !navigator.onLine
37+
);
38+
39+
const refetchRef = useRef(refetchFn);
40+
const isRefetchingRef = useRef(false);
41+
42+
useEffect(() => {
43+
refetchRef.current = refetchFn;
44+
}, [refetchFn]);
45+
46+
useEffect(() => {
47+
if (typeof document === 'undefined') return;
48+
49+
const handleVisibilityChange = () => {
50+
setIsHidden(document.hidden);
51+
};
52+
53+
document.addEventListener('visibilitychange', handleVisibilityChange);
54+
return () => {
55+
document.removeEventListener('visibilitychange', handleVisibilityChange);
56+
};
57+
}, []);
58+
59+
useEffect(() => {
60+
if (typeof window === 'undefined') return;
61+
62+
const handleOnline = () => setIsOffline(false);
63+
const handleOffline = () => setIsOffline(true);
64+
65+
window.addEventListener('online', handleOnline);
66+
window.addEventListener('offline', handleOffline);
67+
return () => {
68+
window.removeEventListener('online', handleOnline);
69+
window.removeEventListener('offline', handleOffline);
70+
};
71+
}, []);
72+
73+
const shouldPoll =
74+
enabled &&
75+
!manualPause &&
76+
!(pauseOnHidden && isHidden) &&
77+
!(pauseOnOffline && isOffline);
78+
79+
const getPauseReason = useCallback((): 'hidden' | 'offline' | 'manual' | null => {
80+
if (manualPause) return 'manual';
81+
if (pauseOnHidden && isHidden) return 'hidden';
82+
if (pauseOnOffline && isOffline) return 'offline';
83+
return null;
84+
}, [manualPause, isHidden, isOffline, pauseOnHidden, pauseOnOffline]);
85+
86+
const doRefetch = useCallback(async () => {
87+
if (isRefetchingRef.current) return;
88+
isRefetchingRef.current = true;
89+
try {
90+
await refetchRef.current();
91+
} finally {
92+
isRefetchingRef.current = false;
93+
}
94+
}, []);
95+
96+
useEffect(() => {
97+
if (!shouldPoll || interval <= 0) {
98+
setIsPolling(false);
99+
return;
100+
}
101+
102+
setIsPolling(true);
103+
104+
const pollInterval = setInterval(() => {
105+
doRefetch();
106+
}, interval);
107+
108+
return () => {
109+
clearInterval(pollInterval);
110+
setIsPolling(false);
111+
};
112+
}, [shouldPoll, interval, doRefetch]);
113+
114+
const pause = useCallback(() => {
115+
setManualPause(true);
116+
}, []);
117+
118+
const resume = useCallback(() => {
119+
setManualPause(false);
120+
}, []);
121+
122+
const forceRefresh = useCallback(() => {
123+
doRefetch();
124+
}, [doRefetch]);
125+
126+
return {
127+
isPolling,
128+
isPaused: !shouldPoll && enabled,
129+
pauseReason: enabled ? getPauseReason() : null,
130+
pause,
131+
resume,
132+
forceRefresh,
133+
};
134+
}

0 commit comments

Comments
 (0)