Skip to content

Commit 505ee74

Browse files
authored
Merge pull request #2865 from rushi-tekdi/feat-tracking-revamp
Content Real-Time Tracking Improvement [Web App]
2 parents 404f133 + cd9067d commit 505ee74

File tree

18 files changed

+1422
-16
lines changed

18 files changed

+1422
-16
lines changed

apps/learner-web-app/public/sw.js

Lines changed: 864 additions & 0 deletions
Large diffs are not rendered by default.

apps/learner-web-app/src/app/ClientLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import React, { useEffect } from 'react';
44
import { FontSizeProvider } from '../context/FontSizeContext';
55
import { UnderlineLinksProvider } from '../context/UnderlineLinksContext';
66
import { telemetryFactory } from '@shared-lib-v2/DynamicForm/utils/telemetry';
7+
import ServiceWorkerRegister from '@learner/components/ServiceWorkerRegister/ServiceWorkerRegister';
78

89
export default function ClientLayout({
910
children,
@@ -16,6 +17,7 @@ export default function ClientLayout({
1617

1718
return (
1819
<FontSizeProvider>
20+
<ServiceWorkerRegister />
1921
<UnderlineLinksProvider>{children}</UnderlineLinksProvider>
2022
</FontSizeProvider>
2123
);
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
5+
const SW_PATH = '/sw.js';
6+
7+
/** Must match shared-lib Interceptor (Bearer token). */
8+
const TOKEN_STORAGE_KEY = 'token';
9+
const TENANT_STORAGE_KEY = 'tenantId';
10+
/** Must match learner tracking queue key convention in IndexedDB (SW filters by this substring). */
11+
const USER_ID_STORAGE_KEY = 'userId';
12+
13+
/** Must match `LS_IN_PROGRESS_KEY` in public/sw.js (page mirror of SW lock). */
14+
export const TRACKING_API_SYNC_LS_KEY = 'trackingApiSyncInProgress';
15+
16+
function buildTrackingSyncPayload() {
17+
let temp_base = process.env.NEXT_PUBLIC_MIDDLEWARE_URL || '';
18+
const base = temp_base.replace(/\/$/, '');
19+
20+
const contentCreateUrl = `${base}/tracking/content/create`;
21+
const courseStatusUrl = `${base}/tracking/content/course/status`;
22+
const userCertificateStatusUpdateUrl = `${base}/tracking/user_certificate/status/update`;
23+
const authUrl = `${base}/user/auth`;
24+
const userCertificateStatusGetUrl = `${base}/tracking/user_certificate/status/get`;
25+
const courseHierarchyUrl = `${base}/api/course/v1/hierarchy/`;
26+
const issueCertificateUrl = `${base}/tracking/certificate/issue`;
27+
const assessmentStatusUrl = `${base}/tracking/assessment/search/status`;
28+
return {
29+
type: 'SET_TRACKING_SYNC_CONFIG' as const,
30+
contentCreateUrl,
31+
courseStatusUrl,
32+
userCertificateStatusUpdateUrl,
33+
authUrl,
34+
userCertificateStatusGetUrl,
35+
courseHierarchyUrl,
36+
issueCertificateUrl,
37+
assessmentStatusUrl,
38+
token: localStorage.getItem(TOKEN_STORAGE_KEY) || '',
39+
tenantId: localStorage.getItem(TENANT_STORAGE_KEY) || '',
40+
userId: localStorage.getItem(USER_ID_STORAGE_KEY) || '',
41+
};
42+
}
43+
44+
function pushTrackingConfig(registration: ServiceWorkerRegistration) {
45+
if (!registration.active) return;
46+
registration.active.postMessage(buildTrackingSyncPayload());
47+
}
48+
49+
function requestIdbReadFromSw(registration: ServiceWorkerRegistration) {
50+
const key = process.env.NEXT_PUBLIC_SW_IDB_PROBE_KEY;
51+
if (!key || !registration.active) return;
52+
registration.active.postMessage({ type: 'IDB_GET', key });
53+
}
54+
55+
/**
56+
* Registers the origin-scoped service worker, pushes auth + tracking API URL to the SW
57+
* (SW cannot read localStorage), and mirrors SW sync lock to localStorage.
58+
*/
59+
export default function ServiceWorkerRegister() {
60+
useEffect(() => {
61+
if (typeof window === 'undefined') return;
62+
if (!('serviceWorker' in navigator)) return;
63+
64+
const onMessage = (event: MessageEvent) => {
65+
const d = event.data;
66+
if (!d || typeof d !== 'object') return;
67+
if (d.type === 'LEARNER_SW_STATUS') {
68+
console.log(
69+
'[learner-sw → page]',
70+
d.reason,
71+
'online=',
72+
d.online,
73+
d.time
74+
);
75+
}
76+
if (d.type === 'LEARNER_SW_IDB_RESULT') {
77+
console.log('[learner-sw → page] IDB', d.key, d.value, d.time);
78+
}
79+
if (d.type === 'LEARNER_SW_IDB_ERROR') {
80+
console.error(
81+
'[learner-sw → page] IDB error',
82+
d.key,
83+
d.error,
84+
d.time
85+
);
86+
}
87+
if (d.type === 'TRACKING_SYNC_LOCK') {
88+
const lsKey =
89+
typeof d.localStorageKey === 'string'
90+
? d.localStorageKey
91+
: TRACKING_API_SYNC_LS_KEY;
92+
if (d.inProgress) {
93+
localStorage.setItem(lsKey, 'true');
94+
} else {
95+
localStorage.removeItem(lsKey);
96+
}
97+
}
98+
};
99+
100+
navigator.serviceWorker.addEventListener('message', onMessage);
101+
102+
const onStorage = (e: StorageEvent) => {
103+
if (
104+
e.key === TOKEN_STORAGE_KEY ||
105+
e.key === TENANT_STORAGE_KEY ||
106+
e.key === USER_ID_STORAGE_KEY
107+
) {
108+
void navigator.serviceWorker.ready.then(pushTrackingConfig);
109+
}
110+
};
111+
window.addEventListener('storage', onStorage);
112+
113+
const onFocus = () => {
114+
void navigator.serviceWorker.ready.then(pushTrackingConfig);
115+
};
116+
window.addEventListener('focus', onFocus);
117+
118+
let cancelled = false;
119+
let configIntervalId: ReturnType<typeof setInterval> | undefined;
120+
121+
void navigator.serviceWorker
122+
.register(SW_PATH)
123+
.then(() => navigator.serviceWorker.ready)
124+
.then((registration) => {
125+
if (cancelled) return;
126+
pushTrackingConfig(registration);
127+
requestIdbReadFromSw(registration);
128+
configIntervalId = setInterval(() => {
129+
pushTrackingConfig(registration);
130+
}, 60000);
131+
})
132+
.catch((err) => {
133+
console.error('[learner-sw] registration failed', err);
134+
});
135+
136+
return () => {
137+
cancelled = true;
138+
if (configIntervalId) clearInterval(configIntervalId);
139+
navigator.serviceWorker.removeEventListener('message', onMessage);
140+
window.removeEventListener('storage', onStorage);
141+
window.removeEventListener('focus', onFocus);
142+
};
143+
}, []);
144+
145+
return null;
146+
}

libs/shared-lib-v2/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export * from './lib/context/LanguageContext';
1515
export * from './utils/API/Interceptor';
1616
export * from './utils/API/RestClient';
1717
export * from './utils/DataClient';
18+
export * from './utils/customIdbStore';
19+
export * from './utils/trackingContentQueueLookup';
1820
export * from './lib/CertificateModal/CertificateModal';
1921
export * from './lib/CourseCompletionBanner/CourseCompletionBanner';
2022
export * from './utils/helper';

libs/shared-lib-v2/src/lib/Card/CommonCard.tsx

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import CardActions from '@mui/material/CardActions';
99
import Avatar from '@mui/material/Avatar';
1010
import Typography from '@mui/material/Typography';
1111
import { red } from '@mui/material/colors';
12-
import { Box, LinearProgress, useTheme } from '@mui/material';
12+
import { Box, IconButton, LinearProgress, useTheme } from '@mui/material';
1313
import { CircularProgressWithLabel } from '../Progress/CircularProgressWithLabel';
1414
import SpeakableText from '../textToSpeech/SpeakableText';
1515
import { capitalize } from 'lodash';
@@ -18,6 +18,7 @@ import TripOriginOutlinedIcon from '@mui/icons-material/TripOriginOutlined';
1818
import PanoramaFishEyeIcon from '@mui/icons-material/PanoramaFishEye';
1919
import AdjustIcon from '@mui/icons-material/Adjust';
2020
import { useTranslation } from '../context/LanguageContext';
21+
import { hasQueuedTrackingForContentId } from '../../utils/trackingContentQueueLookup';
2122

2223
export interface ContentItem {
2324
name: string;
@@ -105,6 +106,20 @@ export const CommonCard: React.FC<CommonCardProps> = ({
105106
const [statusBar, setStatusBar] = React.useState<StatuPorps>();
106107
const { t } = useTranslation();
107108

109+
const [isTrackingSyncPending, setIsTrackingSyncPending] =
110+
React.useState(false);
111+
112+
React.useEffect(() => {
113+
const checkTrackingSyncPending = async () => {
114+
const isPending = await hasQueuedTrackingForContentId(item?.identifier);
115+
116+
// console.log('#asaasdas isPending===>', isPending);
117+
// console.log('#asaasdas item?.identifier===>', item?.identifier);
118+
setIsTrackingSyncPending(isPending);
119+
};
120+
checkTrackingSyncPending();
121+
}, [item?.identifier]);
122+
108123
React.useEffect(() => {
109124
const init = () => {
110125
try {
@@ -188,10 +203,16 @@ export const CommonCard: React.FC<CommonCardProps> = ({
188203
/>
189204
)}
190205

191-
{/* Progress Bar Overlay */}
192-
{/* Progress Bar Overlay */}
193-
{!_card?.isHideProgressStatus && (
194-
<StatusBar {...statusBar} _card={_card} />
206+
{isTrackingSyncPending === true ? (
207+
<OfflineStatusBar />
208+
) : (
209+
<>
210+
{/* Progress Bar Overlay */}
211+
{/* Progress Bar Overlay */}
212+
{!_card?.isHideProgressStatus && (
213+
<StatusBar {...statusBar} _card={_card} />
214+
)}
215+
</>
195216
)}
196217
</Box>
197218
{avatarLetter && subheader && (
@@ -363,6 +384,75 @@ export const CommonCard: React.FC<CommonCardProps> = ({
363384
);
364385
};
365386

387+
export const OfflineStatusBar: React.FC = () => {
388+
const { t } = useTranslation();
389+
390+
return (
391+
<Box
392+
sx={{
393+
position: 'absolute',
394+
top: 0,
395+
bottom: 0,
396+
width: '100%',
397+
display: 'flex',
398+
alignItems: 'center',
399+
background: 'rgba(0, 0, 0, 0.5)',
400+
}}
401+
>
402+
<Box
403+
sx={{
404+
width: '100%',
405+
pl: '6px',
406+
pr: '6px',
407+
py: '6px',
408+
fontSize: '14px',
409+
lineHeight: '20px',
410+
fontWeight: '500',
411+
color: '#50EE42',
412+
display: 'flex',
413+
alignItems: 'center',
414+
gap: '8px',
415+
}}
416+
>
417+
<Typography
418+
variant="h3"
419+
component="div"
420+
sx={{
421+
minWidth: '81px',
422+
letterSpacing: '0.1px',
423+
verticalAlign: 'middle',
424+
}}
425+
>
426+
<SpeakableText>
427+
{t('COMMON.STATUS.tracking_sync_pending')}
428+
</SpeakableText>
429+
</Typography>
430+
<IconButton
431+
size="small"
432+
aria-label="reload"
433+
onClick={() => window.location.reload()}
434+
sx={{ ml: 1, color: '#50EE42', background: 'rgba(255,255,255,0.1)' }}
435+
>
436+
<svg
437+
width="18"
438+
height="18"
439+
viewBox="0 0 24 24"
440+
fill="none"
441+
stroke="currentColor"
442+
strokeWidth="2"
443+
strokeLinecap="round"
444+
strokeLinejoin="round"
445+
>
446+
<polyline points="23 4 23 10 17 10"></polyline>
447+
<polyline points="1 20 1 14 7 14"></polyline>
448+
<path d="M3.51 9a9 9 0 0 1 14.13-3.36L23 10M1 14l5.37 5.36A9 9 0 0 0 20.49 15"></path>
449+
</svg>
450+
</IconButton>
451+
</Box>
452+
</Box>
453+
);
454+
};
455+
366456
export const StatusBar: React.FC<StatuPorps> = ({
367457
trackProgress,
368458
status,

libs/shared-lib-v2/src/lib/context/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@
158158
"completed": "Completed",
159159
"in_progress": "In Progress",
160160
"enrolled_not_started": "Enrolled, not started",
161-
"not_started": "Not Started"
161+
"not_started": "Not Started",
162+
"tracking_sync_pending": "Tracking Sync Pending"
162163
},
163164
"CHANGE_USER": "Change User",
164165
"FETCH_USER": "Fetch User",

libs/shared-lib-v2/src/lib/context/locales/guj.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@
106106
"completed": "પૂર્ણ થયું",
107107
"in_progress": "પ્રગતિ પર",
108108
"enrolled_not_started": "નોંધણી, શરૂ થયું નથી",
109-
"not_started": "શરૂ થયું નથી"
109+
"not_started": "શરૂ થયું નથી",
110+
"tracking_sync_pending": "ટ્રેકિંગ સિન્ક પેન્ડિંગ"
110111
},
111112
"BACK": "પાછળ",
112113
"RETURN_TO_LOGIN": "લોગિન પર પાછા ફરો",

libs/shared-lib-v2/src/lib/context/locales/hi.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@
107107
"completed": "पूरा हुआ",
108108
"in_progress": "प्रगति पर",
109109
"enrolled_not_started": "नामांकित, शुरू नहीं हुआ",
110-
"not_started": "शुरू नहीं हुआ"
110+
"not_started": "शुरू नहीं हुआ",
111+
"tracking_sync_pending": "ट्रैकिंग सिंक पेंडिंग"
111112
},
112113
"BACK": "पीछे",
113114
"RETURN_TO_LOGIN": "लॉगिन पर वापस लौटें",

libs/shared-lib-v2/src/lib/context/locales/kan.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@
107107
"completed": "ಮುಗಿಯಿತು",
108108
"in_progress": "ಪ್ರಗತಿಯಲ್ಲಿ",
109109
"enrolled_not_started": "ਦਾਖಲ, ಶುರು ಆಗಿಲ್ಲ",
110-
"not_started": "ಶುರು ಆಗಿಲ್ಲ"
110+
"not_started": "ಶುರು ಆಗಿಲ್ಲ",
111+
"tracking_sync_pending": "ಟ್ರೇಕಿಂಗ ಸಿನ್ಕ ಪೇನ್ಡಿಂಗ"
111112
},
112113
"BACK": "ಹಿಂದೆ",
113114
"RETURN_TO_LOGIN": "ಲಾಗಿನ್‌ಗೆ ಹಿಂತಿರುಗಿ",

libs/shared-lib-v2/src/lib/context/locales/mr.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@
107107
"completed": "पूर्ण झाले",
108108
"in_progress": "चालू",
109109
"enrolled_not_started": "नोंदणीकृत, सुरू झाले नाही",
110-
"not_started": "सुरू झाले नाही"
110+
"not_started": "सुरू झाले नाही",
111+
"tracking_sync_pending": "ट्रैकिंग सिंक पेंडिंग"
111112
},
112113
"BACK": "मागे",
113114
"RETURN_TO_LOGIN": "परत_लॉगिन करा",

0 commit comments

Comments
 (0)