Skip to content

Commit 8900f87

Browse files
committed
DBC22-6585: highlighted bulletin for new updates
1 parent ef70fd2 commit 8900f87

3 files changed

Lines changed: 219 additions & 46 deletions

File tree

src/frontend/src/Components/bulletins/BulletinsList.js

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,16 @@ import Skeleton from 'react-loading-skeleton';
1616
// Static files
1717
import logo from '../../images/dbc-logo--white.svg';
1818

19-
export default function Bulletins(props) {
20-
// State, props and context
21-
const { bulletins, showLoader } = props;
19+
export default function BulletinsList(props) {
20+
// Props
21+
const { bulletins, showLoader, trackedBulletins = {}, bulletinRefs, dismissHighlight } = props;
2222

2323
// Navigation
2424
const navigate = useNavigate();
2525

2626
function handleClick(bulletin) {
2727
trackEvent('click', 'bulletins-list', 'Bulletin', bulletin.title, bulletin.teaser);
28+
if (dismissHighlight) dismissHighlight(bulletin.id);
2829
navigate(`/bulletins/${bulletin.slug}`);
2930
}
3031

@@ -33,9 +34,14 @@ export default function Bulletins(props) {
3334
// Rendering
3435
return (
3536
<ul className='bulletins-list'>
36-
{!!sortedBulletins && sortedBulletins.map((bulletin, index) => {
37+
{!!sortedBulletins && sortedBulletins.map((bulletin) => {
3738
return (
38-
<li className='bulletin-li' key={bulletin.id} onClick={() => handleClick(bulletin)}
39+
<li
40+
className={`bulletin-li${trackedBulletins[bulletin.id]?.highlight ? ' highlighted' : ''}`}
41+
key={bulletin.id}
42+
data-key={bulletin.id}
43+
ref={(el) => { if (bulletinRefs) bulletinRefs.current[bulletin.id] = el; }}
44+
onClick={() => handleClick(bulletin)}
3945
onKeyDown={(keyEvent) => {
4046
if (['Enter', 'NumpadEnter'].includes(keyEvent.key)) {
4147
handleClick(bulletin);
@@ -45,7 +51,6 @@ export default function Bulletins(props) {
4551
<div className='bulletin-li-title-container' tabIndex={0}>
4652
{showLoader ?
4753
<p><Skeleton height={30} /></p> :
48-
4954
<h2 className='bulletin-li-title'>{bulletin.title}</h2>
5055
}
5156

@@ -54,18 +59,21 @@ export default function Bulletins(props) {
5459
<Skeleton height={10} />
5560
<Skeleton height={10} />
5661
</p> :
57-
5862
(bulletin.teaser &&
5963
<div className="bulletin-li-body">{bulletin.teaser}</div>
6064
)
6165
}
6266

6367
{showLoader ?
6468
<p><Skeleton width={150} height={10} /></p> :
65-
6669
<div className='timestamp-container'>
67-
<span className='bulletin-li-state'>{bulletin.first_published_at != bulletin.last_published_at ? 'Updated' : 'Published' }</span>
70+
<span className='bulletin-li-state'>
71+
{bulletin.first_published_at != bulletin.last_published_at ? 'Updated' : 'Published'}
72+
</span>
6873
<FriendlyTime date={bulletin.latest_revision_created_at} />
74+
{trackedBulletins[bulletin.id]?.highlight &&
75+
<div className="updated-pill">Updated</div>
76+
}
6977
</div>
7078
}
7179
</div>
@@ -74,7 +82,6 @@ export default function Bulletins(props) {
7482
<div className={bulletin.image_url ? 'bulletin-li-thumbnail' : 'bulletin-li-thumbnail-default'}>
7583
{showLoader ?
7684
<Skeleton width={320} height={200} /> :
77-
7885
<img className='thumbnail-logo' src={bulletin.image_url ? bulletin.image_url : logo} alt={bulletin.image_alt_text} />
7986
}
8087
</div>
Lines changed: 161 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
// React
2-
import React, { useCallback, useContext, useEffect, useState, useRef } from 'react';
3-
import { useSearchParams } from "react-router-dom";
2+
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
3+
44
// Redux
55
import { useSelector, useDispatch } from 'react-redux'
66
import { memoize } from 'proxy-memoize'
77
import { updateBulletins } from '../slices/cmsSlice';
88

99
// External imports
1010
import Container from 'react-bootstrap/Container';
11-
12-
// Styling
13-
import './BulletinsListPage.scss';
11+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
12+
import { faArrowUp, faArrowDown } from '@fortawesome/pro-regular-svg-icons';
1413

1514
// Internal imports
1615
import { CMSContext } from '../App';
@@ -24,10 +23,11 @@ import Footer from '../Footer';
2423
import PageHeader from '../PageHeader';
2524
import PollingComponent from "../Components/shared/PollingComponent";
2625

26+
// Styling
27+
import './BulletinsListPage.scss';
28+
2729
export default function BulletinsListPage() {
2830
document.title = 'DriveBC - Bulletins';
29-
// const [searchParams] = useSearchParams();
30-
// const isPreview = searchParams.get("preview") === "true";
3131

3232
// Context
3333
const { cmsContext, setCMSContext } = useContext(CMSContext);
@@ -38,61 +38,170 @@ export default function BulletinsListPage() {
3838
bulletins: state.cms.bulletins.list,
3939
}))));
4040

41+
// Refs
42+
const isInitialLoad = useRef(true);
43+
const bulletinRefs = useRef({});
44+
const viewedHighlightedBulletins = useRef(new Set());
45+
const bulletinsInViewport = useRef({});
46+
const trackedBulletinsRef = useRef({});
47+
const dismissedHighlightsRef = useRef(
48+
new Set(JSON.parse(sessionStorage.getItem('dismissedBulletinHighlights') || '[]'))
49+
);
50+
const highlightedBeforeRefreshRef = useRef(
51+
new Set(JSON.parse(sessionStorage.getItem('highlightedBulletins') || '[]'))
52+
);
53+
4154
// States
55+
const [showLoader, setShowLoader] = useState(true);
56+
const [trackedBulletins, setTrackedBulletins] = useState({});
57+
const [updateCounts, setUpdateCounts] = useState({ above: 0, below: 0 });
4258
const [showNetworkError, setShowNetworkError] = useState(false);
4359
const [showServerError, setShowServerError] = useState(false);
44-
const [showLoader, setShowLoader] = useState(true);
4560

4661
// Error handling
4762
const displayError = (error) => {
4863
if (error instanceof ServerError) {
4964
setShowServerError(true);
50-
5165
} else if (error instanceof NetworkError) {
5266
setShowNetworkError(true);
5367
}
5468
}
5569

56-
// Refs
57-
const isInitialMount = useRef(true);
70+
const dismissHighlight = (bulletinId) => {
71+
dismissedHighlightsRef.current.add(String(bulletinId));
72+
sessionStorage.setItem('dismissedBulletinHighlights', JSON.stringify([...dismissedHighlightsRef.current]));
73+
74+
highlightedBeforeRefreshRef.current.delete(String(bulletinId));
75+
sessionStorage.setItem('highlightedBulletins', JSON.stringify([...highlightedBeforeRefreshRef.current]));
76+
};
5877

5978
// Data loading
6079
const loadBulletins = async () => {
61-
// Skip loading if the bulletins are already loaded on launch
62-
if (bulletins && isInitialMount.current) {
63-
isInitialMount.current = false;
64-
setShowLoader(false);
65-
return;
66-
}
67-
68-
let bulletinsData = bulletins;
69-
if (!bulletinsData) {
70-
bulletinsData = await getBulletins().catch((error) => displayError(error));
80+
const bulletinsData = await getBulletins().catch((error) => displayError(error));
81+
if (!bulletinsData) return;
82+
83+
const trackedDict = bulletinsData.reduce((acc, bulletin) => {
84+
const tracked = trackedBulletinsRef.current[bulletin.id] ?? null;
85+
const isDismissed = dismissedHighlightsRef.current.has(String(bulletin.id));
86+
const wasHighlighted = highlightedBeforeRefreshRef.current.has(String(bulletin.id));
87+
const revisionChanged = tracked && bulletin.live_revision !== tracked.live_revision;
88+
89+
if (revisionChanged) {
90+
dismissedHighlightsRef.current.delete(String(bulletin.id));
91+
sessionStorage.setItem('dismissedBulletinHighlights', JSON.stringify([...dismissedHighlightsRef.current]));
92+
}
7193

72-
dispatch(updateBulletins({
73-
list: bulletinsData,
74-
timeStamp: new Date().getTime()
75-
}));
76-
}
94+
acc[bulletin.id] = {
95+
highlight: isDismissed
96+
? false
97+
: tracked
98+
? revisionChanged || tracked.highlight
99+
: wasHighlighted || !isInitialLoad.current,
100+
live_revision: bulletin.live_revision,
101+
};
102+
return acc;
103+
}, {});
104+
105+
// Persist highlighted IDs to sessionStorage
106+
const highlightedIds = Object.entries(trackedDict)
107+
.filter(([, v]) => v.highlight)
108+
.map(([id]) => id);
109+
sessionStorage.setItem('highlightedBulletins', JSON.stringify(highlightedIds));
110+
highlightedBeforeRefreshRef.current = new Set(highlightedIds);
111+
112+
// Merge into ref instead of overwriting
113+
trackedBulletinsRef.current = {
114+
...trackedBulletinsRef.current,
115+
...trackedDict,
116+
};
117+
setTrackedBulletins({ ...trackedBulletinsRef.current });
118+
119+
dispatch(updateBulletins({
120+
list: bulletinsData,
121+
timeStamp: new Date().getTime()
122+
}));
77123

78-
isInitialMount.current = false;
79124
markBulletinsAsRead(bulletinsData, cmsContext, setCMSContext);
125+
126+
isInitialLoad.current = false;
80127
setShowLoader(false);
81128
}
82129

83130
useEffect(() => {
84131
loadBulletins();
85-
}, [showLoader]);
132+
}, []);
133+
134+
// Intersection observer
135+
useEffect(() => {
136+
const observer = new IntersectionObserver(
137+
(entries) => {
138+
entries.forEach((entry) => {
139+
const bulletinId = entry.target.getAttribute('data-key');
140+
const isHighlighted = trackedBulletins[bulletinId]?.highlight;
141+
142+
if (entry.isIntersecting) {
143+
bulletinsInViewport.current[bulletinId] = null;
144+
if (isHighlighted && !viewedHighlightedBulletins.current.has(bulletinId)) {
145+
viewedHighlightedBulletins.current.add(bulletinId);
146+
}
147+
} else {
148+
delete bulletinsInViewport.current[bulletinId];
149+
}
150+
});
151+
152+
// Count highlighted items above/below viewport
153+
const counts = { above: 0, below: 0 };
154+
Object.entries(bulletinRefs.current).forEach(([id, ref]) => {
155+
const isHighlighted = trackedBulletins[id]?.highlight;
156+
if (!ref || !isHighlighted || viewedHighlightedBulletins.current.has(id)) return;
157+
const top = ref.getBoundingClientRect().top;
158+
top < 0 ? counts.above++ : counts.below++;
159+
});
160+
setUpdateCounts(counts);
161+
},
162+
{ rootMargin: '-58px 0px 0px 0px', threshold: 1 }
163+
);
164+
165+
setTimeout(() => {
166+
Object.values(bulletinRefs.current).forEach((ref) => {
167+
if (ref) observer.observe(ref);
168+
});
169+
}, 0);
170+
171+
return () => observer.disconnect();
172+
}, [bulletins, trackedBulletins]);
173+
174+
const scrollToNextHighlightedHandler = (direction) => {
175+
const offset = 58 + 48;
176+
const sortedRefs = Object.entries(bulletinRefs.current)
177+
.filter(([id]) => trackedBulletins[id]?.highlight && !viewedHighlightedBulletins.current.has(id))
178+
.map(([id, ref]) => ({
179+
id,
180+
top: Math.floor(ref.getBoundingClientRect().top + window.scrollY - offset)
181+
}))
182+
.sort((a, b) => a.top - b.top);
183+
184+
const currentScroll = Math.floor(window.scrollY);
185+
let nextTop;
186+
187+
if (direction === 'above') {
188+
nextTop = sortedRefs.reverse().find(r => r.top < currentScroll)?.top;
189+
} else {
190+
nextTop = sortedRefs.find(r => r.top > currentScroll)?.top;
191+
}
192+
193+
if (nextTop !== undefined) {
194+
document.querySelector('#main')?.scrollTo({ top: nextTop, behavior: 'smooth' });
195+
}
196+
};
86197

87198
const isBulletinsEmpty = bulletins?.length === 0;
88199

89200
return (
90201
<div className='bulletins-page cms-page'>
91-
<PollingComponent runnable={() => setShowLoader(true)} interval={30000} />
92-
{showNetworkError &&
93-
<NetworkErrorPopup />
94-
}
202+
<PollingComponent runnable={loadBulletins} interval={5000} />
95203

204+
{showNetworkError && <NetworkErrorPopup />}
96205
{!showNetworkError && showServerError &&
97206
<ServerErrorPopup setShowServerError={setShowServerError} />
98207
}
@@ -104,13 +213,29 @@ export default function BulletinsListPage() {
104213

105214
<Container>
106215
{isBulletinsEmpty ?
107-
<EmptyBulletin/> :
108-
109-
<BulletinsList bulletins={bulletins} showLoader={showLoader} />
216+
<EmptyBulletin /> :
217+
<BulletinsList
218+
bulletins={bulletins}
219+
showLoader={showLoader}
220+
trackedBulletins={trackedBulletins}
221+
bulletinRefs={bulletinRefs}
222+
dismissHighlight={dismissHighlight}
223+
/>
110224
}
111225
</Container>
112226

227+
{updateCounts.above > 0 &&
228+
<button className="update-count-pill top" onClick={() => scrollToNextHighlightedHandler('above')}>
229+
<FontAwesomeIcon icon={faArrowUp} /> {updateCounts.above} update{updateCounts.above !== 1 ? 's' : ''} available
230+
</button>
231+
}
232+
{updateCounts.below > 0 &&
233+
<button className="update-count-pill bottom" onClick={() => scrollToNextHighlightedHandler('below')}>
234+
<FontAwesomeIcon icon={faArrowDown} /> {updateCounts.below} update{updateCounts.below !== 1 ? 's' : ''} available
235+
</button>
236+
}
237+
113238
<Footer />
114239
</div>
115240
);
116-
}
241+
}

src/frontend/src/pages/BulletinsListPage.scss

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,44 @@
122122
}
123123
}
124124
}
125+
126+
// Highlighted row
127+
.bulletin-li.highlighted {
128+
background-color: var(--highlighted-row-bg, #fff8e1);
129+
border-left: 3px solid var(--colour-primary, #003366);
130+
transition: background-color 0.3s ease;
131+
}
132+
133+
// "Updated" pill — reuse same class as EventsTable
134+
.updated-pill {
135+
display: inline-flex;
136+
align-items: center;
137+
padding: 2px 8px;
138+
border-radius: 12px;
139+
background-color: var(--colour-primary, #003366);
140+
color: #fff;
141+
font-size: 0.75rem;
142+
font-weight: 600;
143+
margin-left: 8px;
144+
}
145+
146+
// Floating scroll pills — reuse same class as EventsListPage
147+
.update-count-pill {
148+
position: fixed;
149+
left: 50%;
150+
transform: translateX(-50%);
151+
background-color: var(--colour-primary, #003366);
152+
color: #fff;
153+
border: none;
154+
border-radius: 20px;
155+
padding: 8px 16px;
156+
font-size: 0.875rem;
157+
cursor: pointer;
158+
z-index: 100;
159+
display: flex;
160+
align-items: center;
161+
gap: 8px;
162+
163+
&.top { top: 80px; }
164+
&.bottom { bottom: 24px; }
165+
}

0 commit comments

Comments
 (0)