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 ? : - {bulletin.image_alt_text} }
      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 }) { Government of British Columbia - {/* 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; } +}