diff --git a/src/backend/apps/cms/migrations/0026_advisory_last_notified_at_bulletin_last_notified_at.py b/src/backend/apps/cms/migrations/0026_advisory_last_notified_at_bulletin_last_notified_at.py
new file mode 100644
index 000000000..091c4ee84
--- /dev/null
+++ b/src/backend/apps/cms/migrations/0026_advisory_last_notified_at_bulletin_last_notified_at.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.14 on 2026-06-10 17:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('cms', '0025_advisoryindexpage_bulletinindexpage_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='advisory',
+ name='last_notified_at',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='bulletin',
+ name='last_notified_at',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/src/backend/apps/cms/models.py b/src/backend/apps/cms/models.py
index d32370d63..b6dcb9681 100644
--- a/src/backend/apps/cms/models.py
+++ b/src/backend/apps/cms/models.py
@@ -196,6 +196,8 @@ def rendered_body(self):
# Geo fields
geometry = models.MultiPolygonField()
+ last_notified_at = models.DateTimeField(null=True, blank=True)
+
# Editor panels configuration
content_panels = [
FieldPanel("title", help_text=HelpText.GENERIC_TITLE),
@@ -209,7 +211,7 @@ def rendered_body(self):
FieldPanel("body", help_text=HelpText.GENERIC_BODY),
FieldPanel('created_at', read_only=True, heading="Created"),
FieldPanel('first_published_at', read_only=True, heading="Published"),
- FieldPanel('last_published_at', read_only=True, heading="Updated"),
+ FieldPanel('last_notified_at', read_only=True, heading="Updated"),
]
promote_panels = []
@@ -252,6 +254,7 @@ class Bulletin(Page, BaseModel):
body = StreamField(RichContent())
image = models.ForeignKey(Image, on_delete=models.SET_NULL, null=True, blank=False)
image_alt_text = models.CharField(max_length=125, default='', blank=False)
+ last_notified_at = models.DateTimeField(null=True, blank=True)
def rendered_body(self):
blocks = [wagtailcore_tags.richtext(block.render()) for block in self.body]
@@ -280,7 +283,7 @@ def save(self, *args, **kwargs):
FieldPanel("body", help_text=HelpText.GENERIC_BODY),
FieldPanel('created_at', read_only=True, heading="Created"),
FieldPanel('first_published_at', read_only=True, heading="Published"),
- FieldPanel('last_published_at', read_only=True, heading="Updated"),
+ FieldPanel('last_notified_at', read_only=True, heading="Updated"),
]
promote_panels = []
diff --git a/src/backend/apps/cms/views.py b/src/backend/apps/cms/views.py
index 7b8c1b80c..4a9b7e793 100644
--- a/src/backend/apps/cms/views.py
+++ b/src/backend/apps/cms/views.py
@@ -15,6 +15,9 @@
from django.template.loader import render_to_string
from django.views.decorators.csrf import csrf_exempt
from rest_framework import viewsets
+from django.contrib import messages
+from django.shortcuts import redirect
+from wagtail.models import Page
class CMSViewSet(viewsets.ReadOnlyModelViewSet):
@@ -146,3 +149,30 @@ def access_denied_idir(request):
return render(request, 'wagtailadmin/access_denied.html', context={
"is_non_idir_login": True,
})
+
+
+def publish_minor_update(request, page_id):
+ if request.method != "POST":
+ return redirect(f"/drivebc-cms/pages/{page_id}/edit/")
+
+ page = Page.objects.get(pk=page_id).specific
+ latest_revision = page.get_latest_revision()
+
+ if latest_revision:
+ # Snapshot the current last_notified_at before publishing
+ last_notified_at = page.last_notified_at
+
+ # Publish the revision (updates content but triggers after_publish_page)
+ latest_revision.publish(changed=False)
+
+ # Restore last_notified_at and also revert last_published_at
+ # so the "Updated" panel field doesn't change
+ Page.objects.filter(pk=page.pk).update(
+ last_published_at=last_notified_at
+ )
+ type(page).objects.filter(pk=page.pk).update(
+ last_notified_at=last_notified_at
+ )
+
+ messages.success(request, f'"{page.title}" published as a minor update.')
+ return redirect(f"/drivebc-cms/pages/{page_id}/edit/")
diff --git a/src/backend/apps/cms/wagtail_hooks.py b/src/backend/apps/cms/wagtail_hooks.py
index 95cb5383d..9f539a6e6 100644
--- a/src/backend/apps/cms/wagtail_hooks.py
+++ b/src/backend/apps/cms/wagtail_hooks.py
@@ -13,7 +13,7 @@
from wagtail_modeladmin.options import ModelAdmin, modeladmin_register
from .models import Advisory, Bulletin, SubPage, get_or_create_advisory_index, get_or_create_bulletin_index
-from .views import access_requested
+from .views import access_requested, publish_minor_update
# from wagtail.admin.ui.components import ActionMenuItem
from wagtail.admin.action_menu import ActionMenuItem
@@ -28,14 +28,21 @@
@hooks.register("after_publish_page")
def post_edit_hook(request, page):
- # Only process published advisory pages
+ from django.utils import timezone
+
if page.specific_class == Advisory:
try:
+ Advisory.objects.filter(pk=page.pk).update(last_notified_at=timezone.now())
send_advisory_notifications(page.id)
-
except Exception:
logger.error(request, 'There was a problem sending an advisory notification')
+ elif page.specific_class == Bulletin:
+ try:
+ Bulletin.objects.filter(pk=page.pk).update(last_notified_at=timezone.now())
+ except Exception:
+ logger.error(request, 'There was a problem updating bulletin notification timestamp')
+
@hooks.register("insert_global_admin_css")
def insert_global_admin_css():
@@ -207,6 +214,49 @@ def render_html(self, parent_context):
preview_url,
)
+class PublishMinorUpdateMenuItem(ActionMenuItem):
+ label = "Publish minor update"
+ name = "publish-minor-update"
+ icon_name = "upload"
+ order = 15
+
+ def render_html(self, parent_context):
+ context = parent_context.get("context", parent_context)
+ page = context.get("page")
+
+ if not page:
+ return ""
+
+ url = reverse("cms-publish-minor-update", args=[page.pk])
+
+ if isinstance(page.specific, Advisory):
+ redirect_url = "/drivebc-cms/cms/advisory/"
+ elif isinstance(page.specific, Bulletin):
+ redirect_url = "/drivebc-cms/cms/bulletin/"
+ else:
+ redirect_url = "/drivebc-cms/"
+
+ return format_html(
+ """
+
+ """,
+ url,
+ redirect_url,
+ )
+
@hooks.register("register_page_action_menu_item")
def register_copy_preview_button():
@@ -281,3 +331,21 @@ def move_new_advisory_to_top(request, page):
if page.specific_class == Advisory:
parent = page.get_parent()
page.move(parent, pos="first-child")
+
+@hooks.register("register_page_action_menu_item")
+def register_publish_minor_update():
+ return PublishMinorUpdateMenuItem(order=25)
+
+@hooks.register("construct_page_action_menu")
+def customize_page_action_menu(menu_items, request, context):
+ for item in menu_items:
+ if item.name == "action-publish":
+ item.label = "Publish with notifications"
+
+
+@hooks.register('register_admin_urls')
+def add_access_requested_url():
+ return [
+ path('access-requested', access_requested, name='cms-access-requested'),
+ path('publish-minor-update//', publish_minor_update, name='cms-publish-minor-update'),
+ ]
\ No newline at end of file
diff --git a/src/frontend/src/Components/advisories/AdvisoriesList.js b/src/frontend/src/Components/advisories/AdvisoriesList.js
index c292cb28c..f0d86f93d 100644
--- a/src/frontend/src/Components/advisories/AdvisoriesList.js
+++ b/src/frontend/src/Components/advisories/AdvisoriesList.js
@@ -1,5 +1,5 @@
// React
-import React, { useContext } from 'react';
+import React, { useContext, useRef } from 'react';
// Navigation
import { useNavigate } from 'react-router-dom';
@@ -30,7 +30,8 @@ export default function AdvisoriesList(props) {
const navigate = useNavigate();
// Props
- const { advisories, showDescription, showTimestamp, showPublished, isAdvisoriesListPage, showLoader } = props;
+ const { advisories, showDescription, showTimestamp, showPublished, isAdvisoriesListPage, showLoader, trackedAdvisories = {}, advisoryRefs } = props;
+ const { dismissHighlight } = props;
function handleClick(advisory, keyEvent) {
// Ignore key presses that aren't enter or space
@@ -39,6 +40,12 @@ export default function AdvisoriesList(props) {
}
trackEvent('click', 'advisories-list', 'Advisory', advisory.title, advisory.teaser);
+ // dismissedHighlightsRef.current.add(advisory.id);
+ // sessionStorage.setItem(
+ // 'dismissedHighlights',
+ // JSON.stringify([...dismissedHighlightsRef.current])
+ // );
+ dismissHighlight(advisory.id); // use parent's function
navigate(`/advisories/${advisory.slug}`);
}
@@ -50,7 +57,12 @@ export default function AdvisoriesList(props) {
{!!sortedAdvisories && sortedAdvisories.map((advisory, index) => {
if (isAdvisoriesListPage) {
return (
-
+
{ if (advisoryRefs) advisoryRefs.current[advisory.id] = el; }}
+ >
:
-
{advisory.first_published_at != advisory.last_published_at ? "Updated" : "Published" }
-
+
{advisory.last_notified_at != advisory.first_published_at ? "Updated" : "Published" }
+
+ {trackedAdvisories[advisory.id]?.highlight &&
+
Updated
+ }
}
@@ -95,8 +110,11 @@ export default function AdvisoriesList(props) {
:
-
{advisory.first_published_at != advisory.last_published_at ? "Updated" : "Published"}
-
+ {advisory.last_notified_at != advisory.first_published_at ? "Updated" : "Published"}
+
+ {trackedAdvisories[advisory.id]?.highlight &&
+ Updated
+ }
}
@@ -122,7 +140,8 @@ export default function AdvisoriesList(props) {
} else {
return (
-
handleClick(advisory)}
onKeyDown={(keyEvent) => handleClick(advisory, keyEvent)}
@@ -139,8 +158,11 @@ export default function AdvisoriesList(props) {
{(showTimestamp && showPublished) &&
{!cmsContext.readAdvisories.includes(advisory.id.toString() + '-' + advisory.live_revision.toString()) &&
}
-
{advisory.first_published_at != advisory.last_published_at ? "Updated" : "Published" }
-
+
{advisory.last_notified_at != advisory.first_published_at ? "Updated" : "Published" }
+
+ {trackedAdvisories[advisory.id]?.highlight &&
+
Updated
+ }
}
@@ -165,7 +187,10 @@ export default function AdvisoriesList(props) {
{showTimestamp &&
{advisory.first_published_at != advisory.last_published_at ? "Updated" : "Published" }
-
+
+ {trackedAdvisories[advisory.id]?.highlight &&
+
Updated
+ }
}
diff --git a/src/frontend/src/Components/bulletins/BulletinsList.js b/src/frontend/src/Components/bulletins/BulletinsList.js
index 792f7e9b4..ec81344d9 100644
--- a/src/frontend/src/Components/bulletins/BulletinsList.js
+++ b/src/frontend/src/Components/bulletins/BulletinsList.js
@@ -16,15 +16,16 @@ import Skeleton from 'react-loading-skeleton';
// Static files
import logo from '../../images/dbc-logo--white.svg';
-export default function Bulletins(props) {
- // State, props and context
- const { bulletins, showLoader } = props;
+export default function BulletinsList(props) {
+ // Props
+ const { bulletins, showLoader, trackedBulletins = {}, bulletinRefs, dismissHighlight } = props;
// Navigation
const navigate = useNavigate();
function handleClick(bulletin) {
trackEvent('click', 'bulletins-list', 'Bulletin', bulletin.title, bulletin.teaser);
+ if (dismissHighlight) dismissHighlight(bulletin.id);
navigate(`/bulletins/${bulletin.slug}`);
}
@@ -33,9 +34,14 @@ export default function Bulletins(props) {
// Rendering
return (
- {!!sortedBulletins && sortedBulletins.map((bulletin, index) => {
+ {!!sortedBulletins && sortedBulletins.map((bulletin) => {
return (
- - handleClick(bulletin)}
+
- { if (bulletinRefs) bulletinRefs.current[bulletin.id] = el; }}
+ onClick={() => handleClick(bulletin)}
onKeyDown={(keyEvent) => {
if (['Enter', 'NumpadEnter'].includes(keyEvent.key)) {
handleClick(bulletin);
@@ -45,7 +51,6 @@ export default function Bulletins(props) {
{showLoader ?
:
-
{bulletin.title}
}
@@ -54,7 +59,6 @@ export default function Bulletins(props) {
:
-
(bulletin.teaser &&
{bulletin.teaser}
)
@@ -62,10 +66,14 @@ export default function Bulletins(props) {
{showLoader ?
:
-
-
{bulletin.first_published_at != bulletin.last_published_at ? 'Updated' : 'Published' }
-
+
+ {bulletin.first_published_at != bulletin.last_notified_at ? 'Updated' : 'Published'}
+
+
+ {trackedBulletins[bulletin.id]?.highlight &&
+
Updated
+ }
}
@@ -74,7 +82,6 @@ export default function Bulletins(props) {
{showLoader ?
:
-

}
diff --git a/src/frontend/src/Components/data/advisories.js b/src/frontend/src/Components/data/advisories.js
index 1954addf1..230c4b10e 100644
--- a/src/frontend/src/Components/data/advisories.js
+++ b/src/frontend/src/Components/data/advisories.js
@@ -24,14 +24,28 @@ export const getAdvisoryCounts = (advisories) => {
}
}
-export const markAdvisoriesAsRead = (advisoriesData, cmsContext, setCMSContext) => {
- if (advisoriesData && advisoriesData.length > 0 && !advisoriesData[0].live_revision) return;
- const advisoriesIds = advisoriesData.map(advisory => advisory.id.toString() + '-' + advisory.live_revision.toString());
+// export const markAdvisoriesAsRead = (advisories, cmsContext, setCMSContext) => {
+// const newRead = advisories
+// .filter(a => a.id && a.last_notified_at)
+// .map(a => a.id.toString() + '-' + a.last_notified_at.toString());
- // Combine and remove duplicates
- const readAdvisories = Array.from(new Set([...advisoriesIds, ...cmsContext.readAdvisories]));
- const updatedContext = {...cmsContext, readAdvisories: readAdvisories};
+// const merged = [...new Set([...cmsContext.readAdvisories, ...newRead])];
+// setCMSContext(prev => ({ ...prev, readAdvisories: merged }));
+// }
- setCMSContext(updatedContext);
- localStorage.setItem('cmsContext', JSON.stringify(updatedContext));
-}
+export const markAdvisoriesAsRead = (advisories, cmsContext, setCMSContext) => {
+ if (!advisories) return;
+
+ const newRead = advisories
+ .filter(a => a.id && a.last_notified_at)
+ .map(a => a.id.toString() + '-' + a.last_notified_at.toString());
+
+ if (newRead.length === 0) return;
+
+ const merged = [...new Set([...cmsContext.readAdvisories, ...newRead])];
+
+ // Only update if something actually changed
+ if (merged.length !== cmsContext.readAdvisories.length) {
+ setCMSContext(prev => ({ ...prev, readAdvisories: merged }));
+ }
+}
\ No newline at end of file
diff --git a/src/frontend/src/Components/data/bulletins.js b/src/frontend/src/Components/data/bulletins.js
index 51129f734..0c025c1b3 100644
--- a/src/frontend/src/Components/data/bulletins.js
+++ b/src/frontend/src/Components/data/bulletins.js
@@ -17,14 +17,19 @@ export function getBulletinsPreview(id) {
});
}
-export function markBulletinsAsRead(bulletinsData, cmsContext, setCMSContext) {
- if (bulletinsData && bulletinsData.length > 0 && !bulletinsData[0].live_revision) return;
- const bulletinsIds = bulletinsData.map(bulletin => bulletin.id.toString() + '-' + bulletin.live_revision.toString());
+export const markBulletinsAsRead = (bulletins, cmsContext, setCMSContext) => {
+ if (!bulletins) return;
- // Combine and remove duplicates
- const readBulletins = Array.from(new Set([...bulletinsIds, ...cmsContext.readBulletins]));
- const updatedContext = {...cmsContext, readBulletins: readBulletins};
+ const newRead = bulletins
+ .filter(b => b.id && b.last_notified_at)
+ .map(b => b.id.toString() + '-' + b.last_notified_at.toString());
- setCMSContext(updatedContext);
- localStorage.setItem('cmsContext', JSON.stringify(updatedContext));
-}
+ if (newRead.length === 0) return;
+
+ const merged = [...new Set([...cmsContext.readBulletins, ...newRead])];
+
+ // Only update if something actually changed
+ if (merged.length !== cmsContext.readBulletins.length) {
+ setCMSContext(prev => ({ ...prev, readBulletins: merged }));
+ }
+}
\ No newline at end of file
diff --git a/src/frontend/src/Components/shared/header/Header.js b/src/frontend/src/Components/shared/header/Header.js
index 490ad729c..f51c2e54b 100644
--- a/src/frontend/src/Components/shared/header/Header.js
+++ b/src/frontend/src/Components/shared/header/Header.js
@@ -40,7 +40,7 @@ export default function Header({ isMaintenance }) {
if (isMaintenance) {
return (
- {/* Added class here */}
+
@@ -48,7 +48,6 @@ export default function Header({ isMaintenance }) {
- {/* Ensure the divider shows even in maintenance if screen is large */}
@@ -57,7 +56,7 @@ export default function Header({ isMaintenance }) {
);
}
-
+
const smallScreen = useMediaQuery('only screen and (max-width: 575px)');
// Check current page location
@@ -81,6 +80,7 @@ export default function Header({ isMaintenance }) {
// Context
const { cmsContext } = useContext(CMSContext);
+ const cmsContextRef = useRef(cmsContext);
// States
const [advisoriesCount, setAdvisoriesCount] = useState();
@@ -91,17 +91,47 @@ export default function Header({ isMaintenance }) {
const [isNavbarCollapsed, setIsNavbarCollapsed] = useState(true);
const [isCommercialOpen, setIsCommercialOpen] = useState(false);
const commercialDropdownRef = useRef(null);
+ const isInitialLoad = useRef(true);
+ const loadRef = useRef(null);
+
+ /* Helpers */
+ const getUnreadAdvisoriesCount = (advisoriesData, context) => {
+ if (!advisoriesData) return 0;
+ if (advisoriesData.length !== 0 && advisoriesData[0].last_notified_at == null) return 0;
+
+ const readAdvisories = advisoriesData.filter(advisory => {
+ if (!advisory.id || !advisory.last_notified_at) return true;
+ return context.readAdvisories.includes(
+ advisory.id.toString() + '-' + advisory.last_notified_at.toString()
+ );
+ });
+ return advisoriesData.length - readAdvisories.length;
+}
+
+const getUnreadBulletinsCount = (bulletinsData, context) => {
+ if (!bulletinsData) return 0;
+ if (bulletinsData.length !== 0 && bulletinsData[0].last_notified_at == null) return 0;
+
+ const readBulletins = bulletinsData.filter(bulletin => {
+ if (!bulletin.id || !bulletin.last_notified_at) return true;
+ return context.readBulletins.includes(
+ bulletin.id.toString() + '-' + bulletin.last_notified_at.toString()
+ );
+ });
+ return bulletinsData.length - readBulletins.length;
+}
+
- // Effects
+
+ /* Data loading */
const loadAdvisories = async () => {
- // Skip loading if the advisories are already loaded on launch
- if (advisories && advisories.length > 0 && advisories[0].live_revision != null) {
- setAdvisoriesCount(getUnreadAdvisoriesCount(filteredAdvisories));
+ if (advisories && advisories.length > 0 && advisories[0].last_notified_at != null && isInitialLoad.current) {
+ setAdvisoriesCount(getUnreadAdvisoriesCount(filteredAdvisories, cmsContextRef.current));
+ isInitialLoad.current = false;
return;
}
const path = window.location.pathname;
-
let advisoriesData;
if (path.includes("preview")) {
advisoriesData = await getAdvisoriesPreview();
@@ -109,19 +139,23 @@ export default function Header({ isMaintenance }) {
advisoriesData = await getAdvisories();
}
- const filteredAdvisoriesData = selectedRoute ? filterAdvisoryByRoute(advisoriesData, selectedRoute) : advisoriesData;
+ const filteredAdvisoriesData = selectedRoute
+ ? filterAdvisoryByRoute(advisoriesData, selectedRoute)
+ : advisoriesData;
+
dispatch(updateAdvisories({
list: advisoriesData,
filteredList: filteredAdvisoriesData,
timeStamp: new Date().getTime()
}));
- setAdvisoriesCount(getUnreadAdvisoriesCount(filteredAdvisoriesData));
+ setAdvisoriesCount(getUnreadAdvisoriesCount(filteredAdvisoriesData, cmsContextRef.current));
+ isInitialLoad.current = false;
}
const loadBulletins = async () => {
- let bulletinsData;
const path = window.location.pathname;
+ let bulletinsData;
if (path.includes("preview")) {
bulletinsData = await getBulletinsPreview();
} else {
@@ -133,13 +167,39 @@ export default function Header({ isMaintenance }) {
timeStamp: new Date().getTime()
}));
- setBulletinsCount(getUnreadBulletinsCount(bulletinsData));
+ // setBulletinsCount(getUnreadBulletinsCount(bulletinsData));
+ setBulletinsCount(getUnreadBulletinsCount(bulletinsData, cmsContextRef.current)); // <--
+
}
+ // Always point loadRef to the latest versions of load functions
+ loadRef.current = { loadAdvisories, loadBulletins };
+
+ /* Effects */
+ useEffect(() => {
+ cmsContextRef.current = cmsContext;
+
+ // Recompute counts immediately when read state changes
+ if (filteredAdvisories) {
+ setAdvisoriesCount(getUnreadAdvisoriesCount(filteredAdvisories, cmsContext));
+ }
+ if (bulletins) {
+ setBulletinsCount(getUnreadBulletinsCount(bulletins, cmsContext));
+ }
+ }, [cmsContext]);
+
+ // Load on mount + pathname change + poll every 30s
useEffect(() => {
- loadAdvisories();
- loadBulletins();
- }, [cmsContext, location.pathname]);
+ loadRef.current.loadAdvisories();
+ loadRef.current.loadBulletins();
+
+ const interval = setInterval(() => {
+ loadRef.current.loadAdvisories();
+ loadRef.current.loadBulletins();
+ }, 30000);
+
+ return () => clearInterval(interval);
+ }, [location.pathname]);
useEffect(() => {
if (searchLocationFrom.length && searchLocationTo.length) {
@@ -154,7 +214,6 @@ export default function Header({ isMaintenance }) {
}, [isNavbarCollapsed]);
useEffect(() => {
- // Close dropdown when clicking outside on xLarge screens
const handleClickOutside = (event) => {
if (
xLargeScreen &&
@@ -175,45 +234,6 @@ export default function Header({ isMaintenance }) {
};
}, [xLargeScreen, isCommercialOpen]);
- /* Helpers */
- const getUnreadAdvisoriesCount = (advisoriesData) => {
- if (!advisoriesData) {
- return 0;
- }
-
- if (advisoriesData.length !== 0 && advisoriesData[0].live_revision == null) {
- return 0;
- }
-
- const readAdvisories = advisoriesData.filter(advisory => {
- // Do not count preview items as unread
- if (!advisory.id || !advisory.live_revision) {
- return true;
- }
-
- return cmsContext.readAdvisories.includes(
- advisory.id.toString() + '-' + advisory.live_revision.toString()
- );
- });
-
- return advisoriesData.length - readAdvisories.length;
- }
-
- const getUnreadBulletinsCount = (bulletinsData) => {
- if (!bulletinsData) {
- return 0;
- }
-
- if (bulletinsData.length !== 0 && bulletinsData[0].live_revision == null) {
- return 0;
- }
-
- const readBulletins = bulletinsData.filter(bulletin => cmsContext.readBulletins.includes(
- bulletin.id.toString() + '-' + bulletin.live_revision.toString()
- ));
- return bulletinsData.length - readBulletins.length;
- }
-
/* Handlers */
const onClickActions = () => {
setTimeout(() => setExpanded(false));
@@ -228,7 +248,6 @@ export default function Header({ isMaintenance }) {
}
/* Rendering */
- // Sub components
const getNavLink = (title, count) => {
return (
diff --git a/src/frontend/src/pages/AdvisoriesListPage.js b/src/frontend/src/pages/AdvisoriesListPage.js
index 15fd27722..f84e68431 100644
--- a/src/frontend/src/pages/AdvisoriesListPage.js
+++ b/src/frontend/src/pages/AdvisoriesListPage.js
@@ -1,6 +1,6 @@
// React
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
-import { useSearchParams } from "react-router-dom";
+import { useSearchParams, useLocation } from "react-router-dom";
// Redux
import { useSelector, useDispatch } from 'react-redux'
@@ -9,6 +9,8 @@ import { updateAdvisories } from '../slices/cmsSlice';
// External imports
import Container from 'react-bootstrap/Container';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faArrowUp, faArrowDown } from '@fortawesome/pro-regular-svg-icons';
// Internal imports
import { CMSContext } from '../App';
@@ -31,7 +33,6 @@ export default function AdvisoriesListPage() {
// Context
const { cmsContext, setCMSContext } = useContext(CMSContext);
- const [showLoader, setShowLoader] = useState(true);
// Redux
const dispatch = useDispatch();
@@ -42,58 +43,239 @@ export default function AdvisoriesListPage() {
// Refs
const isInitialMount = useRef(true);
+ const isInitialLoad = useRef(true);
+ const advisoryRefs = useRef({});
+ const viewedHighlightedAdvisories = useRef(new Set());
+ const advisoriesInViewport = useRef({});
// States
+ const [showLoader, setShowLoader] = useState(true);
+ const [trackedAdvisories, setTrackedAdvisories] = useState({});
+ const [updateCounts, setUpdateCounts] = useState({ above: 0, below: 0 });
const [showNetworkError, setShowNetworkError] = useState(false);
const [showServerError, setShowServerError] = useState(false);
+ const trackedAdvisoriesRef = useRef({});
+ const dismissedHighlightsRef = useRef(
+ new Set(JSON.parse(sessionStorage.getItem('dismissedHighlights') || '[]'))
+ );
+ const location = useLocation();
+ const isFirstMount = useRef(true);
+ const dismissHighlight = (advisoryId) => {
+ dismissedHighlightsRef.current.add(String(advisoryId));
+ sessionStorage.setItem(
+ 'dismissedHighlights',
+ JSON.stringify([...dismissedHighlightsRef.current])
+ );
+ highlightedBeforeRefreshRef.current.delete(String(advisoryId));
+ sessionStorage.setItem(
+ 'highlightedAdvisories',
+ JSON.stringify([...highlightedBeforeRefreshRef.current])
+ );
+
+ const advisory = advisoriesRef.current?.find(
+ a => String(a.id) === String(advisoryId)
+ );
+ if (advisory) {
+ markAdvisoriesAsRead([advisory], cmsContextRef.current, setCMSContext);
+ }
+
+ trackedAdvisoriesRef.current = {
+ ...trackedAdvisoriesRef.current,
+ [advisoryId]: {
+ ...trackedAdvisoriesRef.current[advisoryId],
+ highlight: false,
+ }
+ };
+ setTrackedAdvisories({ ...trackedAdvisoriesRef.current });
+ };
+
+ const highlightedBeforeRefreshRef = useRef(
+ new Set(JSON.parse(sessionStorage.getItem('highlightedAdvisories') || '[]'))
+ );
+
+ const cmsContextRef = useRef(cmsContext);
+ const advisoriesRef = useRef(advisories);
// Error handling
const displayError = (error) => {
if (error instanceof ServerError) {
setShowServerError(true);
-
} else if (error instanceof NetworkError) {
setShowNetworkError(true);
}
}
- // Data loading
- const loadAdvisories = async () => {
- // Skip loading if the advisories are already loaded on launch
- if (advisories && isInitialMount.current) {
- isInitialMount.current = false;
- setShowLoader(false);
- return;
+const loadAdvisories = async () => {
+ const advisoriesData = await getAdvisories().catch((error) => displayError(error));
+ if (!advisoriesData) return;
+
+ // Use the latest context directly from the ref
+ const currentContext = cmsContextRef.current;
+
+ const trackedDict = advisoriesData.reduce((acc, advisory) => {
+ const tracked = trackedAdvisoriesRef.current[advisory.id] ?? null;
+ const isDismissed = dismissedHighlightsRef.current.has(String(advisory.id));
+ const wasHighlighted = highlightedBeforeRefreshRef.current.has(String(advisory.id));
+ const notificationChanged = tracked !== null && advisory.last_notified_at !== tracked.last_notified_at;
+
+ if (notificationChanged) {
+ dismissedHighlightsRef.current.delete(String(advisory.id));
+ sessionStorage.setItem('dismissedHighlights', JSON.stringify([...dismissedHighlightsRef.current]));
}
- const advisoriesData = await getAdvisories().catch((error) => displayError(error));
- const filteredAdvisoriesData = selectedRoute ? filterAdvisoryByRoute(advisoriesData, selectedRoute) : advisoriesData;
- dispatch(updateAdvisories({
- list: advisoriesData,
- filteredList: filteredAdvisoriesData,
- timeStamp: new Date().getTime()
- }));
+ const isUnread = advisory.last_notified_at &&
+ !isDismissed &&
+ !currentContext.readAdvisories.includes(
+ advisory.id.toString() + '-' + advisory.last_notified_at.toString()
+ );
- markAdvisoriesAsRead(filteredAdvisoriesData, cmsContext, setCMSContext);
+ acc[advisory.id] = {
+ highlight: isDismissed
+ ? false
+ : tracked !== null
+ ? notificationChanged || tracked.highlight
+ : wasHighlighted || Boolean(isUnread),
+ live_revision: advisory.live_revision,
+ last_notified_at: advisory.last_notified_at,
+ };
+ return acc;
+ }, {});
+
+ trackedAdvisoriesRef.current = {
+ ...trackedAdvisoriesRef.current,
+ ...trackedDict,
+ };
+ setTrackedAdvisories({ ...trackedAdvisoriesRef.current });
+
+ const highlightedIds = Object.entries(trackedDict)
+ .filter(([, v]) => v.highlight)
+ .map(([id]) => id);
+ sessionStorage.setItem('highlightedAdvisories', JSON.stringify(highlightedIds));
+ highlightedBeforeRefreshRef.current = new Set(highlightedIds);
+
+ const filteredAdvisoriesData = selectedRoute
+ ? filterAdvisoryByRoute(advisoriesData, selectedRoute)
+ : advisoriesData;
+
+ dispatch(updateAdvisories({
+ list: advisoriesData,
+ filteredList: filteredAdvisoriesData,
+ timeStamp: new Date().getTime()
+ }));
+
+ isInitialLoad.current = false;
+ isInitialMount.current = false;
+ setShowLoader(false);
+}
- isInitialMount.current = false;
- setShowLoader(false);
- }
useEffect(() => {
+ // Sync ref BEFORE loading so loadAdvisories sees current readAdvisories
+ cmsContextRef.current = cmsContext;
+
+ if (isFirstMount.current) {
+ isFirstMount.current = false;
loadAdvisories();
- }, [showLoader]);
+ return;
+ }
+ // User navigated back — reset so highlights are recalculated fresh
+ trackedAdvisoriesRef.current = {};
+ viewedHighlightedAdvisories.current = new Set();
+ loadAdvisories();
+}, [location.key, cmsContext]);
+
+ // Intersection observer — scroll position + highlight clearing (mirrors Delays pattern)
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ const advisoryId = entry.target.getAttribute('data-key');
+ const isHighlighted = trackedAdvisories[advisoryId]?.highlight;
+
+ if (entry.isIntersecting) {
+ advisoriesInViewport.current[advisoryId] = null;
+ if (isHighlighted && !viewedHighlightedAdvisories.current.has(advisoryId)) {
+ viewedHighlightedAdvisories.current.add(advisoryId);
+ }
+ } else {
+ // delete advisoriesInViewport.current[advisoryId];
+ // if (isHighlighted && viewedHighlightedAdvisories.current.has(advisoryId)) {
+ // viewedHighlightedAdvisories.current.delete(advisoryId);
+ // // updateHighlightHandler({ id: advisoryId, highlight: false });
+ // }
+ delete advisoriesInViewport.current[advisoryId];
+ }
+ });
+
+ // Count highlighted items above/below viewport
+ const counts = { above: 0, below: 0 };
+ Object.entries(advisoryRefs.current).forEach(([id, ref]) => {
+ const isHighlighted = trackedAdvisories[id]?.highlight;
+ if (!ref || !isHighlighted || viewedHighlightedAdvisories.current.has(id)) return;
+ const top = ref.getBoundingClientRect().top;
+ top < 0 ? counts.above++ : counts.below++;
+ });
+ setUpdateCounts(counts);
+ },
+ { rootMargin: '-58px 0px 0px 0px', threshold: 1 }
+ );
+
+ setTimeout(() => {
+ Object.values(advisoryRefs.current).forEach((ref) => {
+ if (ref) observer.observe(ref);
+ });
+ }, 0);
+
+ return () => observer.disconnect();
+ }, [advisories, trackedAdvisories]);
+
+ useEffect(() => {
+ cmsContextRef.current = cmsContext;
+}, [cmsContext]);
+
+useEffect(() => {
+ advisoriesRef.current = advisories;
+}, [advisories]);
+
+ // Handlers
+ const updateHighlightHandler = (updated) => {
+ setTrackedAdvisories((prev) => ({
+ ...prev,
+ [updated.id]: { ...prev[updated.id], highlight: updated.highlight }
+ }));
+ };
+
+ const scrollToNextHighlightedHandler = (direction) => {
+ const offset = 58 + 48;
+ const sortedRefs = Object.entries(advisoryRefs.current)
+ .filter(([id, ref]) => trackedAdvisories[id]?.highlight && !viewedHighlightedAdvisories.current.has(id))
+ .map(([id, ref]) => ({
+ id,
+ top: Math.floor(ref.getBoundingClientRect().top + window.scrollY - offset)
+ }))
+ .sort((a, b) => a.top - b.top);
+
+ const currentScroll = Math.floor(window.scrollY);
+ let nextTop;
+
+ if (direction === 'above') {
+ nextTop = sortedRefs.reverse().find(r => r.top < currentScroll)?.top;
+ } else {
+ nextTop = sortedRefs.find(r => r.top > currentScroll)?.top;
+ }
+
+ if (nextTop !== undefined) {
+ document.querySelector('#main')?.scrollTo({ top: nextTop, behavior: 'smooth' });
+ }
+ };
const isAdvisoriesEmpty = advisories?.length === 0;
return (
-
setShowLoader(true)} interval={30000} />
-
- {showNetworkError &&
-
- }
+
+ {showNetworkError && }
{!showNetworkError && showServerError &&
}
@@ -105,13 +287,30 @@ export default function AdvisoriesListPage() {
{isAdvisoriesEmpty ?
- :
-
-
+ :
+
}
+ {updateCounts.above > 0 &&
+
+ }
+ {updateCounts.below > 0 &&
+
+ }
+
);
-}
+}
\ No newline at end of file
diff --git a/src/frontend/src/pages/AdvisoriesListPage.scss b/src/frontend/src/pages/AdvisoriesListPage.scss
index fca1f8bf3..b13309233 100644
--- a/src/frontend/src/pages/AdvisoriesListPage.scss
+++ b/src/frontend/src/pages/AdvisoriesListPage.scss
@@ -201,3 +201,45 @@
}
}
}
+
+
+// Highlighted row
+.advisory-li.highlighted {
+ background-color: var(--highlighted-row-bg, #fff8e1);
+ border-left: 3px solid var(--colour-primary, #003366);
+ transition: background-color 0.3s ease;
+}
+
+// "Updated" pill — reuse same class as EventsTable
+.updated-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 12px;
+ background-color: var(--colour-primary, #003366);
+ color: #fff;
+ font-size: 0.75rem;
+ font-weight: 600;
+ margin-left: 8px;
+}
+
+// Floating scroll pills — reuse same class as EventsListPage
+.update-count-pill {
+ position: fixed;
+ left: 50%;
+ transform: translateX(-50%);
+ background-color: var(--colour-primary, #003366);
+ color: #fff;
+ border: none;
+ border-radius: 20px;
+ padding: 8px 16px;
+ font-size: 0.875rem;
+ cursor: pointer;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ &.top { top: 80px; }
+ &.bottom { bottom: 24px; }
+}
\ No newline at end of file
diff --git a/src/frontend/src/pages/AdvisoryDetailsPage.js b/src/frontend/src/pages/AdvisoryDetailsPage.js
index 12f0b617b..82997296b 100644
--- a/src/frontend/src/pages/AdvisoryDetailsPage.js
+++ b/src/frontend/src/pages/AdvisoryDetailsPage.js
@@ -31,6 +31,7 @@ import overrides from '../Components/map/overrides.js';
import { faAttributionToggleLabel } from '../Components/map/attributionControlLabels.js';
import ShareURLButton from '../Components/shared/ShareURLButton';
import { BASE_MAP, MAP_STYLE } from '../env';
+import PollingComponent from "../Components/shared/PollingComponent";
// Styling
import './AdvisoryDetailsPage.scss';
@@ -200,6 +201,11 @@ export default function AdvisoryDetailsPage() {
const [showNetworkError, setShowNetworkError] = useState(false);
const [showServerError, setShowServerError] = useState(false);
const [showLoader, setShowLoader] = useState(true);
+ const [showUpdateOverlay, setShowUpdateOverlay] = useState(false);
+ const initialNotifiedAt = useRef(null);
+ const dismissedNotifiedAt = useRef(null);
+ const [latestNotifiedAt, setLatestNotifiedAt] = useState(null);
+ const currentRevision = useRef(null);
// Navigating to
const returnHandler = () => {
@@ -230,7 +236,45 @@ export default function AdvisoryDetailsPage() {
});
}
- // Data function and initialization
+ const checkForUpdates = async () => {
+ try {
+ const latestData = await getAdvisories(params.id);
+
+ if (!latestData) return;
+
+ // Detect any publish (major or minor)
+ const contentChanged =
+ latestData.live_revision !== currentRevision.current;
+
+ if (contentChanged) {
+ setAdvisory(latestData);
+
+ currentRevision.current = latestData.live_revision;
+
+ if (latestData.geometry && mapRef.current) {
+ fitMap(latestData);
+ }
+
+ document.title = `DriveBC - Advisories - ${latestData.title}`;
+ }
+
+ // Detect major update only
+ const latestNotified = latestData.last_notified_at;
+
+ if (
+ latestNotified &&
+ latestNotified !== initialNotifiedAt.current &&
+ latestNotified !== dismissedNotifiedAt.current
+ ) {
+ setLatestNotifiedAt(latestNotified);
+ initialNotifiedAt.current = latestNotified;
+ setShowUpdateOverlay(true);
+ }
+ } catch (error) {
+ // silently ignore
+ }
+ };
+
const loadAdvisory = async () => {
let advisoryData;
@@ -247,6 +291,9 @@ export default function AdvisoryDetailsPage() {
setAdvisory(advisoryData);
if (advisoryData.id) {
+ initialNotifiedAt.current = advisoryData.last_notified_at;
+ currentRevision.current = advisoryData.live_revision;
+
if (!mapRef.current) {
mapRef.current = getMap(advisoryData);
}
@@ -287,6 +334,23 @@ export default function AdvisoryDetailsPage() {
// Rendering
return (
+ {showUpdateOverlay && (
+
+
+ Advisory updated just now
+
+
+
+ )}
+
{showNetworkError &&
}
@@ -333,7 +397,7 @@ export default function AdvisoryDetailsPage() {
? "Updated"
: content !== NOT_FOUND_CONTENT ? "Saved" : ""}
- { content !== NOT_FOUND_CONTENT &&
}
+ { content !== NOT_FOUND_CONTENT &&
}
diff --git a/src/frontend/src/pages/AdvisoryDetailsPage.scss b/src/frontend/src/pages/AdvisoryDetailsPage.scss
index 4a99babf2..32f3e590d 100644
--- a/src/frontend/src/pages/AdvisoryDetailsPage.scss
+++ b/src/frontend/src/pages/AdvisoryDetailsPage.scss
@@ -177,3 +177,40 @@
border-radius: 4px;
font-weight: bold;
}
+
+.update-overlay {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: #013366;
+ color: white;
+ border-radius: 4px;
+ padding: 12px 16px;
+ min-width: 280px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+
+ &__message {
+ flex: 1;
+ font-size: 0.95rem;
+ }
+
+ &__close {
+ background: none;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ font-size: 1rem;
+
+ &:hover {
+ opacity: 0.8;
+ }
+ }
+}
diff --git a/src/frontend/src/pages/BulletinDetailsPage.js b/src/frontend/src/pages/BulletinDetailsPage.js
index 475fbcf73..3a358f840 100644
--- a/src/frontend/src/pages/BulletinDetailsPage.js
+++ b/src/frontend/src/pages/BulletinDetailsPage.js
@@ -1,5 +1,5 @@
// React
-import React, { useContext, useEffect, useState } from 'react';
+import React, { useContext, useEffect, useState, useRef } from 'react';
// Navigation
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
@@ -8,7 +8,8 @@ import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import Container from 'react-bootstrap/Container';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
- faArrowLeft
+ faArrowLeft,
+ faXmark
} from '@fortawesome/pro-solid-svg-icons';
import Skeleton from 'react-loading-skeleton';
@@ -22,6 +23,7 @@ import Footer from '../Footer.js';
import FriendlyTime from '../Components/shared/FriendlyTime';
import renderWagtailBody from '../Components/shared/renderWagtailBody.js';
import ShareURLButton from '../Components/shared/ShareURLButton';
+import PollingComponent from "../Components/shared/PollingComponent";
// Styling
import './BulletinDetailsPage.scss';
@@ -50,6 +52,11 @@ export default function BulletinDetailsPage() {
const [searchParams] = useSearchParams();
const isPreview = searchParams.get("preview") === "true";
+ const [showUpdateOverlay, setShowUpdateOverlay] = useState(false);
+ const initialNotifiedAt = useRef(null);
+ const dismissedNotifiedAt = useRef(null);
+ const [latestNotifiedAt, setLatestNotifiedAt] = useState(null);
+ const currentRevision = useRef(null);
// Error handling
const displayError = (error) => {
@@ -66,6 +73,41 @@ export default function BulletinDetailsPage() {
navigate(-1);
};
+ const checkForUpdates = async () => {
+ try {
+ const latestData = await getBulletins(params.id);
+
+ if (!latestData) return;
+
+ // Detect any publish (major or minor)
+ const contentChanged =
+ latestData.live_revision !== currentRevision.current;
+
+ if (contentChanged) {
+ setBulletin(latestData);
+
+ currentRevision.current = latestData.live_revision;
+
+ document.title = `DriveBC - Bulletins - ${latestData.title}`;
+ }
+
+ // Detect major update only
+ const latestNotified = latestData.last_notified_at;
+
+ if (
+ latestNotified &&
+ latestNotified !== initialNotifiedAt.current &&
+ latestNotified !== dismissedNotifiedAt.current
+ ) {
+ setLatestNotifiedAt(latestNotified);
+ initialNotifiedAt.current = latestNotified;
+ setShowUpdateOverlay(true);
+ }
+ } catch (error) {
+ // silently ignore
+ }
+ };
+
// Data function and initialization
const loadBulletin = async () => {
const bulletinData = await (isPreview ? getBulletinsPreview(params.id) : getBulletins(params.id)).catch((error) => {
@@ -81,6 +123,8 @@ export default function BulletinDetailsPage() {
// Combine and remove duplicates
if (bulletinData.id) {
+ initialNotifiedAt.current = bulletinData.last_notified_at;
+ currentRevision.current = bulletinData.live_revision;
const readBulletins = Array.from(new Set([
...cmsContext.readBulletins,
bulletinData.id.toString() + '-' + ((bulletinData.live_revision != null) ? bulletinData.live_revision.toString() : '')
@@ -107,6 +151,24 @@ export default function BulletinDetailsPage() {
// Rendering
return (
+ {showUpdateOverlay && (
+
+
+ Bulletin updated just now
+
+
+
+ )}
+
+
{showNetworkError &&
}
@@ -154,7 +216,7 @@ export default function BulletinDetailsPage() {
? "Updated"
: content !== NOT_FOUND_CONTENT ? "Saved" : ""}
- { content !== NOT_FOUND_CONTENT &&
}
+ { content !== NOT_FOUND_CONTENT &&
}
diff --git a/src/frontend/src/pages/BulletinDetailsPage.scss b/src/frontend/src/pages/BulletinDetailsPage.scss
index 499a68485..cb83017d3 100644
--- a/src/frontend/src/pages/BulletinDetailsPage.scss
+++ b/src/frontend/src/pages/BulletinDetailsPage.scss
@@ -71,3 +71,40 @@
}
}
}
+
+.update-overlay {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: #013366;
+ color: white;
+ border-radius: 4px;
+ padding: 12px 16px;
+ min-width: 280px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+
+ &__message {
+ flex: 1;
+ font-size: 0.95rem;
+ }
+
+ &__close {
+ background: none;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ font-size: 1rem;
+
+ &:hover {
+ opacity: 0.8;
+ }
+ }
+}
diff --git a/src/frontend/src/pages/BulletinsListPage.js b/src/frontend/src/pages/BulletinsListPage.js
index d60fffb67..7e4d968f2 100644
--- a/src/frontend/src/pages/BulletinsListPage.js
+++ b/src/frontend/src/pages/BulletinsListPage.js
@@ -1,6 +1,6 @@
// React
-import React, { useCallback, useContext, useEffect, useState, useRef } from 'react';
-import { useSearchParams } from "react-router-dom";
+import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
+
// Redux
import { useSelector, useDispatch } from 'react-redux'
import { memoize } from 'proxy-memoize'
@@ -8,9 +8,8 @@ import { updateBulletins } from '../slices/cmsSlice';
// External imports
import Container from 'react-bootstrap/Container';
-
-// Styling
-import './BulletinsListPage.scss';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faArrowUp, faArrowDown } from '@fortawesome/pro-regular-svg-icons';
// Internal imports
import { CMSContext } from '../App';
@@ -23,11 +22,13 @@ import EmptyBulletin from '../Components/bulletins/EmptyBulletin';
import Footer from '../Footer';
import PageHeader from '../PageHeader';
import PollingComponent from "../Components/shared/PollingComponent";
+import { useLocation } from "react-router-dom";
+
+// Styling
+import './BulletinsListPage.scss';
export default function BulletinsListPage() {
document.title = 'DriveBC - Bulletins';
- // const [searchParams] = useSearchParams();
- // const isPreview = searchParams.get("preview") === "true";
// Context
const { cmsContext, setCMSContext } = useContext(CMSContext);
@@ -38,61 +39,250 @@ export default function BulletinsListPage() {
bulletins: state.cms.bulletins.list,
}))));
+ // Refs
+ const isInitialLoad = useRef(true);
+ const bulletinRefs = useRef({});
+ const viewedHighlightedBulletins = useRef(new Set());
+ const bulletinsInViewport = useRef({});
+ const trackedBulletinsRef = useRef({});
+ const dismissedHighlightsRef = useRef(
+ new Set(JSON.parse(sessionStorage.getItem('dismissedBulletinHighlights') || '[]'))
+ );
+ const highlightedBeforeRefreshRef = useRef(
+ new Set(JSON.parse(sessionStorage.getItem('highlightedBulletins') || '[]'))
+ );
+
// States
+ const [showLoader, setShowLoader] = useState(true);
+ const [trackedBulletins, setTrackedBulletins] = useState({});
+ const [updateCounts, setUpdateCounts] = useState({ above: 0, below: 0 });
const [showNetworkError, setShowNetworkError] = useState(false);
const [showServerError, setShowServerError] = useState(false);
- const [showLoader, setShowLoader] = useState(true);
+ const cmsContextRef = useRef(cmsContext);
+ const bulletinsRef = useRef(bulletins);
+
+ const location = useLocation();
+ const isFirstMount = useRef(true);
// Error handling
const displayError = (error) => {
if (error instanceof ServerError) {
setShowServerError(true);
-
} else if (error instanceof NetworkError) {
setShowNetworkError(true);
}
}
- // Refs
- const isInitialMount = useRef(true);
+// const dismissHighlight = (bulletinId) => {
+// dismissedHighlightsRef.current.add(String(bulletinId));
+// sessionStorage.setItem(
+// 'dismissedHighlights',
+// JSON.stringify([...dismissedHighlightsRef.current])
+// );
+// highlightedBeforeRefreshRef.current.delete(String(bulletinId));
+// sessionStorage.setItem(
+// 'highlightedBulletins',
+// JSON.stringify([...highlightedBeforeRefreshRef.current])
+// );
+
+// // Use refs to avoid stale closure
+// const bulletin = bulletinsRef.current?.find(
+// b => String(b.id) === String(bulletinId)
+// );
+// if (bulletin) {
+// markBulletinsAsRead([bulletin], cmsContextRef.current, setCMSContext);
+// }
+
+// // Also clear from trackedBulletins state directly
+// trackedBulletinsRef.current = {
+// ...trackedBulletinsRef.current,
+// [bulletinId]: {
+// ...trackedBulletinsRef.current[bulletinId],
+// highlight: false,
+// }
+// };
+// setTrackedBulletins({ ...trackedBulletinsRef.current });
+// };
+
+ const dismissHighlight = (bulletinId) => {
+ dismissedHighlightsRef.current.add(String(bulletinId));
+ sessionStorage.setItem(
+ 'dismissedHighlights',
+ JSON.stringify([...dismissedHighlightsRef.current])
+ );
+ highlightedBeforeRefreshRef.current.delete(String(bulletinId));
+ sessionStorage.setItem(
+ 'highlightedBulletins',
+ JSON.stringify([...highlightedBeforeRefreshRef.current])
+ );
+
+ const bulletin = bulletinsRef.current?.find(
+ b => String(b.id) === String(bulletinId)
+ );
+ if (bulletin) {
+ markBulletinsAsRead([bulletin], cmsContextRef.current, setCMSContext);
+ }
+
+ trackedBulletinsRef.current = {
+ ...trackedBulletinsRef.current,
+ [bulletinId]: {
+ ...trackedBulletinsRef.current[bulletinId],
+ highlight: false,
+ }
+ };
+ setTrackedBulletins({ ...trackedBulletinsRef.current });
+ };
+
+
+
// Data loading
const loadBulletins = async () => {
- // Skip loading if the bulletins are already loaded on launch
- if (bulletins && isInitialMount.current) {
- isInitialMount.current = false;
- setShowLoader(false);
- return;
- }
+ const bulletinsData = await getBulletins().catch((error) => displayError(error));
+ if (!bulletinsData) return;
- let bulletinsData = bulletins;
- if (!bulletinsData) {
- bulletinsData = await getBulletins().catch((error) => displayError(error));
+ const currentContext = cmsContextRef.current;
- dispatch(updateBulletins({
- list: bulletinsData,
- timeStamp: new Date().getTime()
- }));
- }
+ const trackedDict = bulletinsData.reduce((acc, bulletin) => {
+ const tracked = trackedBulletinsRef.current[bulletin.id] ?? null;
+ const isDismissed = dismissedHighlightsRef.current.has(String(bulletin.id));
+ const wasHighlighted = highlightedBeforeRefreshRef.current.has(String(bulletin.id));
+ const notificationChanged = tracked !== null && bulletin.last_notified_at !== tracked.last_notified_at;
- isInitialMount.current = false;
- markBulletinsAsRead(bulletinsData, cmsContext, setCMSContext);
+ if (notificationChanged) {
+ dismissedHighlightsRef.current.delete(String(bulletin.id));
+ sessionStorage.setItem('dismissedBulletinHighlights', JSON.stringify([...dismissedHighlightsRef.current]));
+ }
+
+ const isUnread = bulletin.last_notified_at &&
+ !isDismissed &&
+ !currentContext.readBulletins.includes(
+ bulletin.id.toString() + '-' + bulletin.last_notified_at.toString()
+ );
+
+ acc[bulletin.id] = {
+ highlight: isDismissed
+ ? false
+ : tracked !== null
+ ? notificationChanged || tracked.highlight
+ : wasHighlighted || Boolean(isUnread),
+ live_revision: bulletin.live_revision,
+ last_notified_at: bulletin.last_notified_at,
+ };
+ return acc;
+ }, {});
+
+ const highlightedIds = Object.entries(trackedDict)
+ .filter(([, v]) => v.highlight)
+ .map(([id]) => id);
+ sessionStorage.setItem('highlightedBulletins', JSON.stringify(highlightedIds));
+ highlightedBeforeRefreshRef.current = new Set(highlightedIds);
+
+ trackedBulletinsRef.current = {
+ ...trackedBulletinsRef.current,
+ ...trackedDict,
+ };
+ setTrackedBulletins({ ...trackedBulletinsRef.current });
+
+ dispatch(updateBulletins({
+ list: bulletinsData,
+ timeStamp: new Date().getTime()
+ }));
+
+ // ❌ REMOVED: markBulletinsAsRead(bulletinsData, cmsContext, setCMSContext);
+
+ isInitialLoad.current = false;
setShowLoader(false);
}
useEffect(() => {
+ cmsContextRef.current = cmsContext;
+
+ if (isFirstMount.current) {
+ isFirstMount.current = false;
+ loadBulletins();
+ return;
+ }
+ trackedBulletinsRef.current = {};
+ viewedHighlightedBulletins.current = new Set();
loadBulletins();
- }, [showLoader]);
+ }, [location.key, cmsContext]);
+
+ // Intersection observer
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ const bulletinId = entry.target.getAttribute('data-key');
+ const isHighlighted = trackedBulletins[bulletinId]?.highlight;
+
+ if (entry.isIntersecting) {
+ bulletinsInViewport.current[bulletinId] = null;
+ if (isHighlighted && !viewedHighlightedBulletins.current.has(bulletinId)) {
+ viewedHighlightedBulletins.current.add(bulletinId);
+ }
+ } else {
+ delete bulletinsInViewport.current[bulletinId];
+ }
+ });
+
+ // Count highlighted items above/below viewport
+ const counts = { above: 0, below: 0 };
+ Object.entries(bulletinRefs.current).forEach(([id, ref]) => {
+ const isHighlighted = trackedBulletins[id]?.highlight;
+ if (!ref || !isHighlighted || viewedHighlightedBulletins.current.has(id)) return;
+ const top = ref.getBoundingClientRect().top;
+ top < 0 ? counts.above++ : counts.below++;
+ });
+ setUpdateCounts(counts);
+ },
+ { rootMargin: '-58px 0px 0px 0px', threshold: 1 }
+ );
+
+ setTimeout(() => {
+ Object.values(bulletinRefs.current).forEach((ref) => {
+ if (ref) observer.observe(ref);
+ });
+ }, 0);
+
+ return () => observer.disconnect();
+ }, [bulletins, trackedBulletins]);
+
+useEffect(() => {
+ bulletinsRef.current = bulletins;
+}, [bulletins]);
+
+ const scrollToNextHighlightedHandler = (direction) => {
+ const offset = 58 + 48;
+ const sortedRefs = Object.entries(bulletinRefs.current)
+ .filter(([id]) => trackedBulletins[id]?.highlight && !viewedHighlightedBulletins.current.has(id))
+ .map(([id, ref]) => ({
+ id,
+ top: Math.floor(ref.getBoundingClientRect().top + window.scrollY - offset)
+ }))
+ .sort((a, b) => a.top - b.top);
+
+ const currentScroll = Math.floor(window.scrollY);
+ let nextTop;
+
+ if (direction === 'above') {
+ nextTop = sortedRefs.reverse().find(r => r.top < currentScroll)?.top;
+ } else {
+ nextTop = sortedRefs.find(r => r.top > currentScroll)?.top;
+ }
+
+ if (nextTop !== undefined) {
+ document.querySelector('#main')?.scrollTo({ top: nextTop, behavior: 'smooth' });
+ }
+ };
const isBulletinsEmpty = bulletins?.length === 0;
return (
-
setShowLoader(true)} interval={30000} />
- {showNetworkError &&
-
- }
+
+ {showNetworkError && }
{!showNetworkError && showServerError &&
}
@@ -104,13 +294,29 @@ export default function BulletinsListPage() {
{isBulletinsEmpty ?
- :
-
-
+ :
+
}
+ {updateCounts.above > 0 &&
+
+ }
+ {updateCounts.below > 0 &&
+
+ }
+
);
-}
+}
\ No newline at end of file
diff --git a/src/frontend/src/pages/BulletinsListPage.scss b/src/frontend/src/pages/BulletinsListPage.scss
index d025bc117..50f78e539 100644
--- a/src/frontend/src/pages/BulletinsListPage.scss
+++ b/src/frontend/src/pages/BulletinsListPage.scss
@@ -122,3 +122,44 @@
}
}
}
+
+// Highlighted row
+.bulletin-li.highlighted {
+ background-color: var(--highlighted-row-bg, #fff8e1);
+ border-left: 3px solid var(--colour-primary, #003366);
+ transition: background-color 0.3s ease;
+}
+
+// "Updated" pill — reuse same class as EventsTable
+.updated-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 12px;
+ background-color: var(--colour-primary, #003366);
+ color: #fff;
+ font-size: 0.75rem;
+ font-weight: 600;
+ margin-left: 8px;
+}
+
+// Floating scroll pills — reuse same class as EventsListPage
+.update-count-pill {
+ position: fixed;
+ left: 50%;
+ transform: translateX(-50%);
+ background-color: var(--colour-primary, #003366);
+ color: #fff;
+ border: none;
+ border-radius: 20px;
+ padding: 8px 16px;
+ font-size: 0.875rem;
+ cursor: pointer;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ &.top { top: 80px; }
+ &.bottom { bottom: 24px; }
+}