From fcf1df3ddb61e1b3bebfeaf6c518555b8f12efe0 Mon Sep 17 00:00:00 2001 From: ugyballoons Date: Tue, 28 Oct 2025 15:57:13 +0000 Subject: [PATCH 01/62] Fix mypy issues --- .../ts/rubintv/handlers/handlers_helpers.py | 2 +- python/lsst/ts/rubintv/handlers/pages.py | 55 ++++++++++--------- python/lsst/ts/rubintv/main.py | 2 +- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/python/lsst/ts/rubintv/handlers/handlers_helpers.py b/python/lsst/ts/rubintv/handlers/handlers_helpers.py index 9c4b1aeb..0cc1f39a 100644 --- a/python/lsst/ts/rubintv/handlers/handlers_helpers.py +++ b/python/lsst/ts/rubintv/handlers/handlers_helpers.py @@ -29,7 +29,7 @@ async def get_camera_current_data( ) -> CameraPageData: """Get the current data for a camera.""" if not camera.online: - return None + return CameraPageData() current_poller: CurrentPoller = connection.app.state.current_poller first_pass: asyncio.Event = connection.app.state.first_pass_event # wait for the first poll to complete diff --git a/python/lsst/ts/rubintv/handlers/pages.py b/python/lsst/ts/rubintv/handlers/pages.py index 46e09d1a..d39be31e 100644 --- a/python/lsst/ts/rubintv/handlers/pages.py +++ b/python/lsst/ts/rubintv/handlers/pages.py @@ -439,11 +439,12 @@ async def get_specific_channel_event_page( type = channel_name day_obs = date_str.replace("-", "") visit = f"{day_obs}{seq_num:05d}" - key = await get_key_from_type_and_visit( - camera_name=camera_name, - type=type, - visit=visit, - ) + if type is not None and visit is not None: + key = await get_key_from_type_and_visit( + camera_name=camera_name, + type=type, + visit=visit, + ) if not key: raise HTTPException(status_code=404, detail="Key not found.") @@ -452,27 +453,27 @@ async def get_specific_channel_event_page( channel_title = "" event_detail = "" next_prev: dict[str, str] = {} - if event: + if event is not None: event_detail = f"{event.day_obs}/${event.seq_num}" channel = find_first(camera.channels, "name", event.channel_name) - if channel: - channel_title = channel.title - next_prev, historical_busy = await try_historical_call( - get_prev_next_event, - location=location, - camera=camera, - event=event, - request=request, - ) - if historical_busy: - next_prev = {} - all_channel_names = await get_all_channel_names_for_date_seq_num( - location=location, - camera=camera, - day_obs=event.day_obs, - seq_num=event.seq_num, - connection=request, - ) + if channel: + channel_title = channel.title + next_prev, historical_busy = await try_historical_call( + get_prev_next_event, + location=location, + camera=camera, + event=event, + request=request, + ) + if historical_busy: + next_prev = {} + all_channel_names = await get_all_channel_names_for_date_seq_num( + location=location, + camera=camera, + day_obs=event.day_obs_date(), + seq_num=event.seq_num_force_int(), + connection=request, + ) title = build_title(location.title, camera.title, channel_title, event_detail) @@ -502,7 +503,7 @@ async def get_current_channel_event_page( location_name: str, camera_name: str, channel_name: str, request: Request ) -> Response: location, camera = await get_location_camera(location_name, camera_name, request) - channel: Channel = find_first(camera.channels, "name", channel_name) + channel: Channel | None = find_first(camera.channels, "name", channel_name) if channel is None or channel not in camera.channels: raise HTTPException(status_code=404, detail="Channel not found.") @@ -517,8 +518,8 @@ async def get_current_channel_event_page( all_channel_names = await get_all_channel_names_for_date_seq_num( location=location, camera=camera, - day_obs=event.day_obs, - seq_num=event.seq_num, + day_obs=event.day_obs_date(), + seq_num=event.seq_num_force_int(), connection=request, ) diff --git a/python/lsst/ts/rubintv/main.py b/python/lsst/ts/rubintv/main.py index e05acb79..bcdbc902 100644 --- a/python/lsst/ts/rubintv/main.py +++ b/python/lsst/ts/rubintv/main.py @@ -41,7 +41,7 @@ exp_checker_installed = False try: - from lsst.ts.exp_checker import app as exp_checker_app + from lsst.ts.exp_checker import app as exp_checker_app # type: ignore[import] logger.info("exp_checker is mounted") exp_checker_installed = True From 342e6c4640a89b766083932b027875f07ee81119 Mon Sep 17 00:00:00 2001 From: ugyballoons Date: Wed, 29 Oct 2025 13:15:18 +0000 Subject: [PATCH 02/62] Add seqNum highlighting from URL query string --- python/lsst/ts/rubintv/handlers/pages.py | 4 +++ python/lsst/ts/rubintv/templates/camera.jinja | 1 + src/js/components/TableApp.tsx | 2 ++ src/js/components/TableView.tsx | 25 +++++++++++++++++-- src/js/components/componentTypes.ts | 4 +++ src/js/pages/camera-table.tsx | 2 ++ src/js/pages/globals.ts | 1 + src/sass/style.sass | 3 +++ 8 files changed, 40 insertions(+), 2 deletions(-) diff --git a/python/lsst/ts/rubintv/handlers/pages.py b/python/lsst/ts/rubintv/handlers/pages.py index d39be31e..1e0502c8 100644 --- a/python/lsst/ts/rubintv/handlers/pages.py +++ b/python/lsst/ts/rubintv/handlers/pages.py @@ -156,6 +156,7 @@ async def get_camera_page( location_name: str, camera_name: str, request: Request, + seq_num: int | None = None, ) -> Response: """GET ``/rubintv/{location_name}/{camera_name}`` (the camera page for the current day).""" @@ -165,6 +166,7 @@ async def get_camera_page( camera_name=camera_name, date_str=day_obs.isoformat(), request=request, + seq_num=seq_num, ) @@ -216,6 +218,7 @@ async def get_camera_for_date_page( camera_name: str, date_str: str, request: Request, + seq_num: int | None = None, ) -> Response: location, camera = await get_location_camera(location_name, camera_name, request) @@ -287,6 +290,7 @@ async def get_camera_for_date_page( "calendar": calendar, "title": title, "isStale": is_stale, + "seqNum": seq_num, }, ) diff --git a/python/lsst/ts/rubintv/templates/camera.jinja b/python/lsst/ts/rubintv/templates/camera.jinja index 66a56b2d..43400bb7 100644 --- a/python/lsst/ts/rubintv/templates/camera.jinja +++ b/python/lsst/ts/rubintv/templates/camera.jinja @@ -62,6 +62,7 @@ window.APP_DATA.nightReportLink = {{ nr_link | tojson }} window.APP_DATA.calendar = {{ calendar | tojson }} window.APP_DATA.isHistorical = {{ isHistorical | tojson }} + window.APP_DATA.seqNum = {{ seqNum | tojson if seqNum is not none else 'undefined' }} {{ super() }} diff --git a/src/js/components/TableApp.tsx b/src/js/components/TableApp.tsx index d95ffdde..d7e1ea0b 100644 --- a/src/js/components/TableApp.tsx +++ b/src/js/components/TableApp.tsx @@ -26,6 +26,7 @@ export default function TableApp({ isHistorical, siteLocation, isStale, + seqNum, }: TableAppProps) { const [hasReceivedData, setHasReceivedData] = useState(false) const [date, setDate] = useState(initialDate) @@ -247,6 +248,7 @@ export default function TableApp({ filteredRowsCount={filteredRowsCount} sortOn={sortOn} siteLocation={siteLocation} + seqNumToShow={seqNum} /> diff --git a/src/js/components/TableView.tsx b/src/js/components/TableView.tsx index e6a0da61..9515dfc7 100644 --- a/src/js/components/TableView.tsx +++ b/src/js/components/TableView.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from "react" +import React, { useContext, useLayoutEffect } from "react" import { useModal } from "../hooks/useModal" import { FilterDialog } from "./TableFilter" import { @@ -118,6 +118,7 @@ function TableRow({ channelRow, metadataColumns, metadataRow, + highlightRow, }: TableRowProps) { const { dayObs, siteLocation } = useContext( RubinTVTableContext @@ -155,8 +156,10 @@ function TableRow({ ? (metadataRow["controller"] as string) : undefined + const rowClass = highlightRow ? "highlight-row" : "" + return ( - + {seqNum} @@ -208,6 +211,7 @@ function TableBody({ metadataColumns, metadata, sortOn, + seqNumToShow, }: TableBodyProps) { const allSeqs = Array.from( new Set(Object.keys(channelData).concat(Object.keys(metadata))) @@ -218,6 +222,8 @@ function TableBody({ {seqs.map((seqNum) => { const metadataRow = seqNum in metadata ? metadata[seqNum] : {} const channelRow = seqNum in channelData ? channelData[seqNum] : {} + const highlightRow = + seqNumToShow !== undefined && Number(seqNum) === seqNumToShow return ( ) })} @@ -386,7 +393,20 @@ export default function TableView({ filterOn, filteredRowsCount, sortOn, + seqNumToShow, }: TableViewProps) { + useLayoutEffect(() => { + if (seqNumToShow !== undefined) { + const highlightedRow = document.querySelector(".highlight-row") + if (highlightedRow) { + highlightedRow.scrollIntoView({ + behavior: "smooth", + block: "center", + }) + } + } + }, [seqNumToShow, filteredRowsCount, sortOn]) + const filterColumnSet = filterOn.column !== "" && filterOn.value !== "" if (filterColumnSet && filteredRowsCount == 0) { return ( @@ -404,6 +424,7 @@ export default function TableView({ metadataColumns={metadataColumns} metadata={metadata} sortOn={sortOn} + seqNumToShow={seqNumToShow} /> ) diff --git a/src/js/components/componentTypes.ts b/src/js/components/componentTypes.ts index 0a176ef7..b29758a2 100644 --- a/src/js/components/componentTypes.ts +++ b/src/js/components/componentTypes.ts @@ -418,6 +418,7 @@ export interface TableAppProps { isHistorical: boolean siteLocation: string isStale: boolean + seqNum?: number } /** @@ -527,6 +528,7 @@ export interface TableRowProps { channelRow: Record metadataColumns: MetadataColumn[] metadataRow: MetadataRow + highlightRow?: boolean } /** @@ -545,6 +547,7 @@ export interface TableBodyProps { metadataColumns: MetadataColumn[] metadata: Metadata sortOn: SortingOptions + seqNumToShow?: number } /** @@ -629,6 +632,7 @@ export interface TableViewProps { filteredRowsCount: number sortOn: SortingOptions siteLocation: string + seqNumToShow?: number } /** diff --git a/src/js/pages/camera-table.tsx b/src/js/pages/camera-table.tsx index fc9a5d34..0aa16b8b 100644 --- a/src/js/pages/camera-table.tsx +++ b/src/js/pages/camera-table.tsx @@ -20,6 +20,7 @@ import { Camera } from "../components/componentTypes" isHistorical, calendar, isStale = false, + seqNum, } = window.APP_DATA const banner = _getById("header-banner") @@ -73,6 +74,7 @@ import { Camera } from "../components/componentTypes" initialDate={date} isStale={isStale} isHistorical={isHistorical} + seqNum={seqNum} /> ) diff --git a/src/js/pages/globals.ts b/src/js/pages/globals.ts index d62d1022..d0fb2bd1 100644 --- a/src/js/pages/globals.ts +++ b/src/js/pages/globals.ts @@ -35,6 +35,7 @@ declare global { event: ExposureEvent | null allChannelNames: string[] isStale: boolean + seqNum: number | undefined } } } diff --git a/src/sass/style.sass b/src/sass/style.sass index 348e85ab..9a9dd95e 100644 --- a/src/sass/style.sass +++ b/src/sass/style.sass @@ -308,6 +308,9 @@ section margin-top: -2px border-collapse: separate + .highlight-row .grid-cell + background-color: #e9feff + .grid-title position: absolute font-size: 14px From ba8cc60bf639f554f6a715feac26a9774e1d4351 Mon Sep 17 00:00:00 2001 From: ugyballoons Date: Thu, 30 Oct 2025 11:47:48 +0000 Subject: [PATCH 03/62] Make the highlighted row stand out more --- src/sass/style.sass | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sass/style.sass b/src/sass/style.sass index 9a9dd95e..3505afec 100644 --- a/src/sass/style.sass +++ b/src/sass/style.sass @@ -309,6 +309,7 @@ section border-collapse: separate .highlight-row .grid-cell + mix-blend-mode: hard-light background-color: #e9feff .grid-title From bd03ead50c41d37d306f154c24a81e08c9ee9745 Mon Sep 17 00:00:00 2001 From: ugyballoons Date: Thu, 30 Oct 2025 15:16:04 +0000 Subject: [PATCH 04/62] Allow seqNum ranges in query --- python/lsst/ts/rubintv/handlers/pages.py | 19 +++++++++++++-- src/js/components/TableView.tsx | 31 ++++++++++++++++++------ src/js/components/componentTypes.ts | 4 +-- src/js/modules/utils.ts | 11 +++++++++ 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/python/lsst/ts/rubintv/handlers/pages.py b/python/lsst/ts/rubintv/handlers/pages.py index 1e0502c8..6c9c0e10 100644 --- a/python/lsst/ts/rubintv/handlers/pages.py +++ b/python/lsst/ts/rubintv/handlers/pages.py @@ -1,5 +1,6 @@ """Handlers for the app's external root, ``/rubintv/``.""" +import re from datetime import date from fastapi import APIRouter, HTTPException, Request @@ -156,7 +157,7 @@ async def get_camera_page( location_name: str, camera_name: str, request: Request, - seq_num: int | None = None, + seq_num: int | str | None = None, ) -> Response: """GET ``/rubintv/{location_name}/{camera_name}`` (the camera page for the current day).""" @@ -218,7 +219,7 @@ async def get_camera_for_date_page( camera_name: str, date_str: str, request: Request, - seq_num: int | None = None, + seq_num: int | str | list[int] | None = None, ) -> Response: location, camera = await get_location_camera(location_name, camera_name, request) @@ -274,6 +275,20 @@ async def get_camera_for_date_page( if no_data_at_all and not historical_busy: template = "camera-empty" + if isinstance(seq_num, str): + sequence_input = re.sub(r"[^0-9,.]", "", seq_num) + elements = sequence_input.split(",") + seq_num_list: list[int] = [] + for el in elements: + try: + seq_num_list.append(int(float(el))) + except ValueError: + pass + if seq_num_list: + seq_num = seq_num_list + else: + seq_num = None + title = build_title(location.title, camera.title, date_str) return templates.TemplateResponse( diff --git a/src/js/components/TableView.tsx b/src/js/components/TableView.tsx index 9515dfc7..ccd86c4d 100644 --- a/src/js/components/TableView.tsx +++ b/src/js/components/TableView.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useLayoutEffect } from "react" +import React, { useContext, useLayoutEffect, useMemo } from "react" import { useModal } from "../hooks/useModal" import { FilterDialog } from "./TableFilter" import { @@ -6,6 +6,7 @@ import { _elWithAttrs, replaceInString, setCameraBaseUrl, + rangeFromArray, } from "../modules/utils" import { RubinTVContextType, @@ -211,19 +212,21 @@ function TableBody({ metadataColumns, metadata, sortOn, - seqNumToShow, + seqNumRange, }: TableBodyProps) { const allSeqs = Array.from( new Set(Object.keys(channelData).concat(Object.keys(metadata))) ) const seqs = applySorting(allSeqs, sortOn, metadata) + const filledSeqRange = + seqNumRange !== undefined ? rangeFromArray(seqNumRange) : undefined + return ( {seqs.map((seqNum) => { const metadataRow = seqNum in metadata ? metadata[seqNum] : {} const channelRow = seqNum in channelData ? channelData[seqNum] : {} - const highlightRow = - seqNumToShow !== undefined && Number(seqNum) === seqNumToShow + const highlightRow = filledSeqRange?.includes(seqNum) ?? false return ( { + if (seqNumToShow === undefined) { + return undefined + } + return typeof seqNumToShow === "number" + ? [seqNumToShow, seqNumToShow] + : [Math.min(...seqNumToShow), Math.max(...seqNumToShow)] + }, [seqNumToShow]) + + // Scroll to highlighted row on initial render. + // Runs only once when component mounts. useLayoutEffect(() => { - if (seqNumToShow !== undefined) { - const highlightedRow = document.querySelector(".highlight-row") + if (seqNumRange !== undefined && seqNumRange.length === 2) { + const firstSeqNum = seqNumRange[1] + const highlightedRow = document.getElementById(`seqNum-${firstSeqNum}`) if (highlightedRow) { highlightedRow.scrollIntoView({ behavior: "smooth", @@ -405,7 +420,7 @@ export default function TableView({ }) } } - }, [seqNumToShow, filteredRowsCount, sortOn]) + }, []) // eslint-disable-line react-hooks/exhaustive-deps const filterColumnSet = filterOn.column !== "" && filterOn.value !== "" if (filterColumnSet && filteredRowsCount == 0) { @@ -424,7 +439,7 @@ export default function TableView({ metadataColumns={metadataColumns} metadata={metadata} sortOn={sortOn} - seqNumToShow={seqNumToShow} + seqNumRange={seqNumRange} /> ) diff --git a/src/js/components/componentTypes.ts b/src/js/components/componentTypes.ts index b29758a2..46df6d2c 100644 --- a/src/js/components/componentTypes.ts +++ b/src/js/components/componentTypes.ts @@ -547,7 +547,7 @@ export interface TableBodyProps { metadataColumns: MetadataColumn[] metadata: Metadata sortOn: SortingOptions - seqNumToShow?: number + seqNumRange?: number[] } /** @@ -632,7 +632,7 @@ export interface TableViewProps { filteredRowsCount: number sortOn: SortingOptions siteLocation: string - seqNumToShow?: number + seqNumToShow?: number | number[] } /** diff --git a/src/js/modules/utils.ts b/src/js/modules/utils.ts index df242130..97de95e5 100644 --- a/src/js/modules/utils.ts +++ b/src/js/modules/utils.ts @@ -388,3 +388,14 @@ export const sanitiseRedisValue = (value: string): string => { value = value.toUpperCase() // Convert to uppercase return value } + +// Function to generate a range of numbers from an array of numbers +export function rangeFromArray(arr?: number[]) { + if (!arr || arr.length === 0) return [] + const [start, end] = [Math.min(...arr), Math.max(...arr)] + const result = [] + for (let i = start; i <= end; i++) { + result.push(i) + } + return result +} From 7d488377835ec3eb07a47a59981e6e21f65f42c4 Mon Sep 17 00:00:00 2001 From: ugyballoons Date: Fri, 31 Oct 2025 14:20:28 +0000 Subject: [PATCH 05/62] Refactor websocket notifiers --- .../rubintv/handlers/websocket_notifiers.py | 126 ++++++++---------- 1 file changed, 57 insertions(+), 69 deletions(-) diff --git a/python/lsst/ts/rubintv/handlers/websocket_notifiers.py b/python/lsst/ts/rubintv/handlers/websocket_notifiers.py index 65323b98..ffaeb7da 100644 --- a/python/lsst/ts/rubintv/handlers/websocket_notifiers.py +++ b/python/lsst/ts/rubintv/handlers/websocket_notifiers.py @@ -142,15 +142,23 @@ async def get_clients_to_notify(service_cam_id: str) -> list[UUID]: return to_notify -async def notify_all_status_change(historical_busy: bool) -> None: - service = Service.HISTORICALSTATUS - messageType = MessageType.HISTORICAL_STATUS - key = service.value - tasks = [] +async def get_websockets_for_service(service_key: str) -> list[WebSocket]: + """Get all websockets for clients subscribed to a service. + + Parameters + ---------- + service_key : str + The service key to look up clients for + + Returns + ------- + list[WebSocket] + List of websockets for clients subscribed to the service + """ async with services_lock: - if key not in services_clients: - return - client_ids = services_clients[key] + if service_key not in services_clients: + return [] + client_ids = services_clients[service_key] # Gather websockets for the clients async with clients_lock: @@ -158,15 +166,49 @@ async def notify_all_status_change(historical_busy: bool) -> None: clients[client_id] for client_id in client_ids if client_id in clients ] - # Prepare tasks for each websocket - for websocket in websockets: - task = send_notification(websocket, service, messageType, historical_busy) - tasks.append(task) + return websockets + + +async def notify_service_clients( + service_key: str, + service: Service, + message_type: MessageType, + payload: Any, +) -> None: + """Send notifications to all clients subscribed to a service. + + Parameters + ---------- + service_key : str + The service key to look up clients for + service : Service + The service enum value + message_type : MessageType + The message type enum value + payload : Any + The payload to send + """ + websockets = await get_websockets_for_service(service_key) + + if not websockets: + return + + tasks = [ + send_notification(websocket, service, message_type, payload) + for websocket in websockets + ] - # Use asyncio.gather to handle all tasks concurrently await asyncio.gather(*tasks, return_exceptions=True) +async def notify_all_status_change(historical_busy: bool) -> None: + service = Service.HISTORICALSTATUS + message_type = MessageType.HISTORICAL_STATUS + service_key = service.value + + await notify_service_clients(service_key, service, message_type, historical_busy) + + async def notify_redis_detector_status(data: dict) -> None: """Notify all clients subscribed to the Redis detector service about status changes. @@ -181,60 +223,6 @@ async def notify_redis_detector_status(data: dict) -> None: """ service = Service.DETECTORS message_type = MessageType.DETECTOR_STATUS - key = service.value - tasks = [] - - async with services_lock: - if key not in services_clients: - return - client_ids = services_clients[key] - - # Gather websockets for the clients - async with clients_lock: - websockets = [ - clients[client_id] for client_id in client_ids if client_id in clients - ] - - # Prepare tasks for each websocket - for websocket in websockets: - task = send_notification(websocket, service, message_type, data) - tasks.append(task) - - # Use asyncio.gather to handle all tasks concurrently - await asyncio.gather(*tasks, return_exceptions=True) - - -async def notify_controls_readback_change(data: dict) -> None: - """Notify all clients subscribed to the ControlsReadback service about - readback changes. - - Parameters - ---------- - data : `dict` - The controls readback data to send to clients. Contains: - - key: The key of the control - - value: The actual readback value from Redis - """ - service = Service.ADMIN - message_type = MessageType.CONTROL_READBACK_CHANGE - key = service.value - tasks = [] - - async with services_lock: - if key not in services_clients: - return - client_ids = services_clients[key] - - # Gather websockets for the clients - async with clients_lock: - websockets = [ - clients[client_id] for client_id in client_ids if client_id in clients - ] - - # Prepare tasks for each websocket - for websocket in websockets: - task = send_notification(websocket, service, message_type, data) - tasks.append(task) + service_key = "detectors" - # Use asyncio.gather to handle all tasks concurrently - await asyncio.gather(*tasks, return_exceptions=True) + await notify_service_clients(service_key, service, message_type, data) From e90d1f5252a711c92c1828d8e4f6e4a5c0091a45 Mon Sep 17 00:00:00 2001 From: ugyballoons Date: Tue, 4 Nov 2025 13:56:35 +0000 Subject: [PATCH 06/62] Implement prev/next day buttons --- .../ts/rubintv/background/historicaldata.py | 21 ++++++ python/lsst/ts/rubintv/handlers/pages.py | 3 +- .../rubintv/handlers/websocket_notifiers.py | 8 +++ python/lsst/ts/rubintv/models/models.py | 1 + src/js/components/TableApp.tsx | 4 +- src/js/components/TableControls.tsx | 72 ++++++++++++++++++- src/js/components/componentTypes.ts | 8 ++- src/js/modules/utils.ts | 34 +++++++++ src/js/pages/camera-table.tsx | 8 +-- src/sass/style.sass | 3 + 10 files changed, 151 insertions(+), 11 deletions(-) diff --git a/python/lsst/ts/rubintv/background/historicaldata.py b/python/lsst/ts/rubintv/background/historicaldata.py index edf03281..4cef9a71 100644 --- a/python/lsst/ts/rubintv/background/historicaldata.py +++ b/python/lsst/ts/rubintv/background/historicaldata.py @@ -128,6 +128,7 @@ async def check_for_new_day(self) -> None: ) await notify_all_status_change(historical_busy=False) + await self.notify_update_all_calendars() else: if self.test_mode: break @@ -148,6 +149,26 @@ async def notify_clients_of_day_change(self) -> None: "from historical", ) + async def notify_update_all_calendars(self) -> None: + """Notify all clients to update their calendars.""" + await asyncio.sleep(3) # slight delay to ensure data is ready + for loc in self._locations: + for cam in loc.cameras: + if cam.online: + loc_cam = f"{loc.name}/{cam.name}" + calendar = self._calendar.get(loc_cam, {}) + logger.info( + "Notifying calendar update for", + loc_cam=loc_cam, + calendar_size=len(calendar), + ) + await notify_ws_clients( + Service.CALENDAR, + MessageType.CALENDAR_UPDATE, + loc_cam, + calendar, + ) + async def _refresh_location_store(self, location: Location) -> None: try: up_to_date_objects = await self._get_objects_for_location(location) diff --git a/python/lsst/ts/rubintv/handlers/pages.py b/python/lsst/ts/rubintv/handlers/pages.py index 6c9c0e10..e7961c46 100644 --- a/python/lsst/ts/rubintv/handlers/pages.py +++ b/python/lsst/ts/rubintv/handlers/pages.py @@ -260,8 +260,7 @@ async def get_camera_for_date_page( raise http_error calendar: dict[int, dict[int, dict[int, int]]] = {} - if is_historical: - calendar = await get_camera_calendar(location, camera, request) + calendar = await get_camera_calendar(location, camera, request) nr_link = "" if not data.is_empty() and data.nr_exists: diff --git a/python/lsst/ts/rubintv/handlers/websocket_notifiers.py b/python/lsst/ts/rubintv/handlers/websocket_notifiers.py index ffaeb7da..1ff5357e 100644 --- a/python/lsst/ts/rubintv/handlers/websocket_notifiers.py +++ b/python/lsst/ts/rubintv/handlers/websocket_notifiers.py @@ -28,6 +28,14 @@ async def notify_ws_clients( ) -> None: service_loc_cam_chan = " ".join([service.value, loc_cam]) to_notify = await get_clients_to_notify(service_loc_cam_chan) + if not to_notify: + return + logger.info( + "Notifying clients", + service=service.value, + message_type=message_type.value, + loc_cam=loc_cam, + ) await notify_clients(to_notify, service, message_type, payload) diff --git a/python/lsst/ts/rubintv/models/models.py b/python/lsst/ts/rubintv/models/models.py index 6eb0345e..c5b505ad 100644 --- a/python/lsst/ts/rubintv/models/models.py +++ b/python/lsst/ts/rubintv/models/models.py @@ -447,6 +447,7 @@ class ServiceMessageTypes(Enum): NIGHT_REPORT = "nightReport" HISTORICAL_STATUS = "historicalStatus" DAY_CHANGE = "dayChange" + CALENDAR_UPDATE = "calendarUpdate" PREV_NEXT = "prevNext" ALL_CHANNELS = "allChannels" DETECTOR_STATUS = "detectorStatus" diff --git a/src/js/components/TableApp.tsx b/src/js/components/TableApp.tsx index d7e1ea0b..1dd3c0ed 100644 --- a/src/js/components/TableApp.tsx +++ b/src/js/components/TableApp.tsx @@ -27,6 +27,7 @@ export default function TableApp({ siteLocation, isStale, seqNum, + calendar, }: TableAppProps) { const [hasReceivedData, setHasReceivedData] = useState(false) const [date, setDate] = useState(initialDate) @@ -36,7 +37,6 @@ export default function TableApp({ column: "", value: "", } as FilterOptions) - const [sortOn, setSortOn] = useState({ column: "seq", order: "desc", @@ -217,11 +217,13 @@ export default function TableApp({
diff --git a/src/js/components/TableControls.tsx b/src/js/components/TableControls.tsx index 05bdb0d6..7243c326 100644 --- a/src/js/components/TableControls.tsx +++ b/src/js/components/TableControls.tsx @@ -1,6 +1,18 @@ -import React, { useEffect, useState, useContext, KeyboardEvent } from "react" +import React, { + useEffect, + useState, + useContext, + KeyboardEvent, + useMemo, +} from "react" import Clock, { TimeSinceLastImageClock } from "./Clock" -import { _getById, getImageAssetUrl } from "../modules/utils" +import { + _getById, + findPrevNextDate, + getCameraPageForDateUrl, + getImageAssetUrl, + unpackCalendarAsDateList, +} from "../modules/utils" import { saveColumnSelection } from "../modules/columnStorage" import { Metadata, @@ -8,10 +20,12 @@ import { AboveTableRowProps, TableControlProps, DownloadMetadataButtonProps, + CalendarData, } from "./componentTypes" import { RubinTVTableContext } from "./contexts/contexts" export default function AboveTableRow({ + locationName, camera, availableColumns, selected, @@ -19,11 +33,63 @@ export default function AboveTableRow({ date, metadata, isHistorical, + calendar, }: AboveTableRowProps) { + const [calendarData, setCalendarData] = useState( + calendar || null + ) + + useEffect(() => { + function handleCalendarEvent(event: CustomEvent) { + const { dataType, data: calendarData } = event.detail + if (dataType !== "calendarUpdate") { + return + } + setCalendarData(calendarData) + } + window.addEventListener("calendar", handleCalendarEvent as EventListener) + return () => { + window.removeEventListener( + "calendar", + handleCalendarEvent as EventListener + ) + } + }, [calendar]) + + const dateList = useMemo( + () => unpackCalendarAsDateList(calendarData || {}), + [calendarData] + ) + const { prevDate, nextDate } = findPrevNextDate(dateList, date) + + function handleJumpToDate(targetDate: string) { + window.location.href = getCameraPageForDateUrl( + locationName, + camera.name, + targetDate + ) + } + return (

- Data for day: {date} + {prevDate && ( + + )} + {date} + {nextDate && ( + + )}

)) { + dateList.push(`${year}-${padMonth}-${day.toString().padStart(2, "0")}`) + } + } + } + return dateList.sort() +} + +export function findPrevNextDate( + dateList: string[], + currentDate: string +): { prevDate: string | null; nextDate: string | null } { + const currentIndex = dateList.indexOf(currentDate) + + let prevDate: string | null = null + let nextDate: string | null = null + + if (currentIndex > 0) { + prevDate = dateList[currentIndex - 1] + } + if (currentIndex >= 0 && currentIndex < dateList.length - 1) { + nextDate = dateList[currentIndex + 1] + } + + return { prevDate, nextDate } +} diff --git a/src/js/pages/camera-table.tsx b/src/js/pages/camera-table.tsx index 0aa16b8b..86c46224 100644 --- a/src/js/pages/camera-table.tsx +++ b/src/js/pages/camera-table.tsx @@ -37,13 +37,12 @@ import { Camera } from "../components/componentTypes" /> ) + const ws = new WebsocketClient() + ws.subscribe("calendar", locationName, camera.name) + if (!isHistorical || isStale) { - const ws = new WebsocketClient() ws.subscribe("camera", locationName, camera.name) } else { - const ws = new WebsocketClient() - ws.subscribe("calendar", locationName, camera.name) - const calendarElement = _getById("calendar") if (!calendarElement) { console.error("Calendar element not found") @@ -75,6 +74,7 @@ import { Camera } from "../components/componentTypes" isStale={isStale} isHistorical={isHistorical} seqNum={seqNum} + calendar={calendar} /> ) diff --git a/src/sass/style.sass b/src/sass/style.sass index 3505afec..61486800 100644 --- a/src/sass/style.sass +++ b/src/sass/style.sass @@ -275,6 +275,9 @@ section #the-date font-size: 2.2em margin-bottom: 0 + display: flex + align-items: center + gap: 0.4em .above-table-sticky min-width: 100% From e175f4405602f624c9c6ca2c648b50393ceaf2a0 Mon Sep 17 00:00:00 2001 From: ugyballoons Date: Tue, 4 Nov 2025 14:08:25 +0000 Subject: [PATCH 07/62] Add aria labels and tests --- src/js/components/TableControls.tsx | 2 + src/js/components/tests/TableControls.test.js | 89 ++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/js/components/TableControls.tsx b/src/js/components/TableControls.tsx index 7243c326..3628a28b 100644 --- a/src/js/components/TableControls.tsx +++ b/src/js/components/TableControls.tsx @@ -79,6 +79,7 @@ export default function AboveTableRow({ onClick={() => { handleJumpToDate(prevDate) }} + aria-label="Jump to previous date" > )} {date} @@ -88,6 +89,7 @@ export default function AboveTableRow({ onClick={() => { handleJumpToDate(nextDate) }} + aria-label="Jump to next date" > )} diff --git a/src/js/components/tests/TableControls.test.js b/src/js/components/tests/TableControls.test.js index a7c190e4..f18f998a 100644 --- a/src/js/components/tests/TableControls.test.js +++ b/src/js/components/tests/TableControls.test.js @@ -5,7 +5,11 @@ import { render, screen, fireEvent } from "@testing-library/react" import AboveTableRow, { JumpButtons } from "../TableControls" import { RubinTVTableContext } from "../contexts/contexts" import { saveColumnSelection } from "../../modules/columnStorage" -import { _getById } from "../../modules/utils" +import { + _getById, + findPrevNextDate, + getCameraPageForDateUrl, +} from "../../modules/utils" /* global jest, describe, it, expect, beforeEach, beforeAll, afterAll */ @@ -23,6 +27,9 @@ jest.mock("../../modules/utils", () => ({ return null }), getImageAssetUrl: jest.fn(() => "mock-arrow.svg"), + findPrevNextDate: jest.fn(() => ({ prevDate: null, nextDate: null })), + getCameraPageForDateUrl: jest.fn(() => "mock-url"), + unpackCalendarAsDateList: jest.fn(() => []), })) // Mock Clock components @@ -53,6 +60,7 @@ describe("AboveTableRow Component", () => { time_since_clock: { label: "Last Image" }, } defaultProps = { + locationName: "test-location", camera: mockCamera, availableColumns: ["colA", "colB", "colC"], selected: ["colA", "colB"], @@ -73,7 +81,6 @@ describe("AboveTableRow Component", () => { ) - expect(screen.getByText("Data for day:")).toBeInTheDocument() expect(screen.getByText("2024-01-01")).toBeInTheDocument() expect(screen.getByText("Add/Remove Columns")).toBeInTheDocument() expect(screen.getByText("Download Metadata")).toBeInTheDocument() @@ -106,6 +113,84 @@ describe("AboveTableRow Component", () => { expect(screen.queryByTestId("time-since-clock")).not.toBeInTheDocument() }) + + it("renders jump-to-date buttons when prev/next dates are available", () => { + // Mock the utils functions to return prev/next dates + findPrevNextDate.mockReturnValue({ + prevDate: "2024-01-01", + nextDate: "2024-01-03", + }) + + render( + + + + ) + + expect( + screen.getByRole("button", { name: "Jump to previous date" }) + ).toBeInTheDocument() + expect( + screen.getByRole("button", { name: "Jump to next date" }) + ).toBeInTheDocument() + }) + + it("does not render jump buttons when no prev/next dates available", () => { + findPrevNextDate.mockReturnValue({ + prevDate: null, + nextDate: null, + }) + + render( + + + + ) + + expect( + screen.queryByRole("button", { name: "Jump to previous date" }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "Jump to next date" }) + ).not.toBeInTheDocument() + }) + + it("navigates to correct URL when jump buttons are clicked", () => { + findPrevNextDate.mockReturnValue({ + prevDate: "2024-01-01", + nextDate: "2024-01-03", + }) + getCameraPageForDateUrl.mockReturnValue("/test-url") + + render( + + + + ) + + const prevButton = screen.getByRole("button", { + name: "Jump to previous date", + }) + fireEvent.click(prevButton) + + expect(getCameraPageForDateUrl).toHaveBeenCalledWith( + "test-location", + "testcam", + "2024-01-01" + ) + + // Test next button as well + const nextButton = screen.getByRole("button", { + name: "Jump to next date", + }) + fireEvent.click(nextButton) + + expect(getCameraPageForDateUrl).toHaveBeenCalledWith( + "test-location", + "testcam", + "2024-01-03" + ) + }) }) describe("TableControls Component", () => { From 8629985893d69025327a937272c483c36d026d6b Mon Sep 17 00:00:00 2001 From: ugyballoons Date: Tue, 4 Nov 2025 14:28:18 +0000 Subject: [PATCH 08/62] Style jump to date buttons --- src/js/components/TableControls.tsx | 4 ++-- src/sass/style.sass | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/js/components/TableControls.tsx b/src/js/components/TableControls.tsx index 3628a28b..7835134e 100644 --- a/src/js/components/TableControls.tsx +++ b/src/js/components/TableControls.tsx @@ -75,7 +75,7 @@ export default function AboveTableRow({

{prevDate && ( )} - {date} + + {date} + {nextDate && ( )} {date} From b70046ef1c63bc81d9bf9e9b37295686aae1bda5 Mon Sep 17 00:00:00 2001 From: ugyballoons Date: Wed, 5 Nov 2025 21:04:00 +0000 Subject: [PATCH 17/62] Scroll to seq_num range even if range limits don't exist --- src/js/components/TableView.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/js/components/TableView.tsx b/src/js/components/TableView.tsx index 969a0ffc..028400e5 100644 --- a/src/js/components/TableView.tsx +++ b/src/js/components/TableView.tsx @@ -409,9 +409,7 @@ export default function TableView({ // Runs only once when component mounts. useLayoutEffect(() => { if (seqNumRange !== undefined && seqNumRange.length === 2) { - // Scroll to the larger seq num in the range - const firstSeqNum = seqNumRange[1] - const highlightedRow = document.getElementById(`seqNum-${firstSeqNum}`) + const highlightedRow = document.querySelector(".highlight-row") if (highlightedRow) { highlightedRow.scrollIntoView({ behavior: "smooth", From 66a5475737a16d5fef2dd13e52a7690205202387 Mon Sep 17 00:00:00 2001 From: ugyballoons Date: Wed, 5 Nov 2025 21:14:17 +0000 Subject: [PATCH 18/62] Revise test --- src/js/components/tests/TableControls.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/components/tests/TableControls.test.js b/src/js/components/tests/TableControls.test.js index f18f998a..233dda5d 100644 --- a/src/js/components/tests/TableControls.test.js +++ b/src/js/components/tests/TableControls.test.js @@ -586,7 +586,7 @@ describe("JumpButtons Component", () => { const topButton = screen.getByTitle("to top") fireEvent.click(topButton) - expect(mockTable.scrollIntoView).toHaveBeenCalledWith() + expect(mockTable.scrollIntoView).toHaveBeenCalledWith(true) }) it("scrolls to bottom when bottom button is clicked", () => { From c619c60f91b1ad32c7072f9e380fb0fc4c8b1b5b Mon Sep 17 00:00:00 2001 From: ugyballoons Date: Thu, 13 Nov 2025 15:12:31 +0000 Subject: [PATCH 19/62] Preserve time-since clock after only new channel data on day rollover --- src/js/components/Clock.tsx | 7 +- src/js/components/TableApp.tsx | 23 +++- src/js/components/componentTypes.ts | 2 + src/js/components/tests/TableControls.test.js | 116 +++++++++++++++++- 4 files changed, 143 insertions(+), 5 deletions(-) diff --git a/src/js/components/Clock.tsx b/src/js/components/Clock.tsx index d346d9cb..a2aadca5 100644 --- a/src/js/components/Clock.tsx +++ b/src/js/components/Clock.tsx @@ -41,6 +41,7 @@ function padZero(num: number): string { export function TimeSinceLastImageClock({ metadata: propsMeta, + lastKnownMetadataRow, camera, }: TimeSinceLastImageClockProps) { const [isOnline, setIsOnline] = useState(true) @@ -95,7 +96,11 @@ export function TimeSinceLastImageClock({ .pop() : undefined - const row = lastSeq !== undefined ? metadata[lastSeq] : undefined + let row = lastSeq !== undefined ? metadata[lastSeq] : undefined + + if (!row && lastKnownMetadataRow) { + row = lastKnownMetadataRow + } if (!row) { return null diff --git a/src/js/components/TableApp.tsx b/src/js/components/TableApp.tsx index d8ff9c2f..dea7ffd9 100644 --- a/src/js/components/TableApp.tsx +++ b/src/js/components/TableApp.tsx @@ -11,6 +11,7 @@ import { TableAppProps, ChannelData, Metadata, + MetadataRow, MetadataColumn, FilterOptions, SortingOptions, @@ -42,6 +43,9 @@ export default function TableApp({ column: "seq", order: "desc", } as SortingOptions) + const [lastKnownMetadataRow, setLastKnownMetadataRow] = useState< + MetadataRow | undefined + >(undefined) const [error, setError] = useState(null) @@ -167,7 +171,22 @@ export default function TableApp({ setError(data.error) } + // Before clearing metadata on day rollover, preserve the last metadata row if (datestamp && datestamp !== date) { + if (Object.keys(metadata).length > 0) { + const lastSeq = Object.keys(metadata) + .map(Number) + .sort((a, b) => a - b) + .pop() + + if (lastSeq !== undefined) { + const lastRow = metadata[lastSeq] + if (lastRow && "Date begin" in lastRow) { + setLastKnownMetadataRow(lastRow) + } + } + } + setDateAndUpdateHeader(datestamp) setMetadata({}) setChannelData({}) @@ -175,11 +194,12 @@ export default function TableApp({ if (dataType === "metadata") { setMetadata(data) + setLastKnownMetadataRow(undefined) } else if (dataType === "channelData") { setChannelData(data) } }, - [date] + [date, metadata] ) useEffect(() => { @@ -226,6 +246,7 @@ export default function TableApp({ calendar={calendar} toggleCalendar={toggleCalendar} metadata={metadata} + lastKnownMetadataRow={lastKnownMetadataRow} isHistorical={isHistorical} />
diff --git a/src/js/components/componentTypes.ts b/src/js/components/componentTypes.ts index 6036ce78..5a3c6d65 100644 --- a/src/js/components/componentTypes.ts +++ b/src/js/components/componentTypes.ts @@ -446,6 +446,7 @@ export interface AboveTableRowProps { isHistorical: boolean calendar?: CalendarData toggleCalendar?: () => void + lastKnownMetadataRow?: MetadataRow } /** @@ -820,6 +821,7 @@ export interface CameraWithTimeSinceClock extends Camera { */ export interface TimeSinceLastImageClockProps { metadata: Metadata + lastKnownMetadataRow?: MetadataRow camera: CameraWithTimeSinceClock } diff --git a/src/js/components/tests/TableControls.test.js b/src/js/components/tests/TableControls.test.js index 233dda5d..ef737885 100644 --- a/src/js/components/tests/TableControls.test.js +++ b/src/js/components/tests/TableControls.test.js @@ -1,7 +1,7 @@ /* global global*/ import "@testing-library/jest-dom" import React from "react" -import { render, screen, fireEvent } from "@testing-library/react" +import { render, screen, fireEvent, act } from "@testing-library/react" import AboveTableRow, { JumpButtons } from "../TableControls" import { RubinTVTableContext } from "../contexts/contexts" import { saveColumnSelection } from "../../modules/columnStorage" @@ -33,13 +33,19 @@ jest.mock("../../modules/utils", () => ({ })) // Mock Clock components +/* eslint-disable react/prop-types */ jest.mock("../Clock", () => ({ __esModule: true, default: () =>
Mock Clock
, - TimeSinceLastImageClock: () => ( -
Mock Time Since Clock
+ TimeSinceLastImageClock: ({ camera }) => ( +
+ {camera?.time_since_clock?.label && ( + {camera.time_since_clock.label} + )} +
), })) +/* eslint-enable react/prop-types */ const mockContextValue = { siteLocation: "summit", @@ -191,6 +197,110 @@ describe("AboveTableRow Component", () => { "2024-01-03" ) }) + + it("continues to show TimeSinceLastImageClock after day roll-over on non-historical page", () => { + const mockCameraWithClock = { + name: "testcam", + channels: [{ name: "channel1", colour: "#ff0000" }], + time_since_clock: { label: "Since last image:" }, + } + + render( + + + + ) + + // Initially, TimeSinceLastImageClock should be rendered for non-historical data + expect(screen.getByTestId("time-since-clock")).toBeInTheDocument() + expect(screen.getByText("Since last image:")).toBeInTheDocument() + + // Simulate day roll-over by dispatching camera event with new datestamp + act(() => { + const rollOverEvent = new CustomEvent("camera", { + detail: { + datestamp: "2024-01-02", // Next day + dataType: "metadata", + data: { + 123: { + "Date begin": "2024-01-02T00:01:00Z", + "Exposure time": 10, + colA: "valueA", + }, + }, + }, + }) + window.dispatchEvent(rollOverEvent) + }) + + // TimeSinceLastImageClock should still be visible after day roll-over + expect(screen.getByTestId("time-since-clock")).toBeInTheDocument() + expect(screen.getByText("Since last image:")).toBeInTheDocument() + }) + + it("continues to show TimeSinceLastImageClock when day rolls over with channelData", () => { + const mockCameraWithClock = { + name: "testcam", + channels: [{ name: "channel1", colour: "#ff0000" }], + time_since_clock: { label: "Since last image:" }, + } + + // Start with some initial metadata to establish a baseline + const initialMetadata = { + 100: { + "Date begin": "2024-01-01T23:58:00Z", + "Exposure time": 15, + colA: "valueA", + }, + } + + render( + + + + ) + + // Initially, TimeSinceLastImageClock should be rendered for non-historical data + expect(screen.getByTestId("time-since-clock")).toBeInTheDocument() + expect(screen.getByText("Since last image:")).toBeInTheDocument() + + // Simulate day roll-over triggered by channelData event (not metadata) + act(() => { + const rollOverEvent = new CustomEvent("camera", { + detail: { + datestamp: "2024-01-02", // Next day + dataType: "channelData", // Key difference: channelData triggers rollover + data: { + 123: { + channel1: "some_channel_data_value", + }, + }, + }, + }) + window.dispatchEvent(rollOverEvent) + }) + + // TimeSinceLastImageClock should STILL be visible after day roll-over + // even though metadata was cleared and no new metadata has arrived yet + expect(screen.getByTestId("time-since-clock")).toBeInTheDocument() + expect(screen.getByText("Since last image:")).toBeInTheDocument() + + // Verify that the component is using the preserved lastKnownMetadataRow + // by checking that it's still functioning (not returning null) + const timeSinceElement = screen.getByTestId("time-since-clock") + expect(timeSinceElement).toBeVisible() + }) }) describe("TableControls Component", () => { From 05cb4faafaa085f0ac36930469f48c856f9f4416 Mon Sep 17 00:00:00 2001 From: ugyballoons Date: Thu, 22 Jan 2026 16:40:12 +0000 Subject: [PATCH 20/62] Add historical breadcrumb and smooth calendar animation --- python/lsst/ts/rubintv/templates/camera.jinja | 9 ++++++++- src/js/components/CameraTable.tsx | 20 +++++++++---------- src/sass/_calendar.sass | 6 +++--- src/sass/style.sass | 3 +++ 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/python/lsst/ts/rubintv/templates/camera.jinja b/python/lsst/ts/rubintv/templates/camera.jinja index c7462dc2..20baea3a 100644 --- a/python/lsst/ts/rubintv/templates/camera.jinja +++ b/python/lsst/ts/rubintv/templates/camera.jinja @@ -15,7 +15,14 @@ {{ location.title }} > -

{{ camera.title }}

+ {% if isHistorical %} + + {{ camera.title }} + > +

Historical

+ {% else %} +

{{ camera.title }}

+ {% endif %} {% endblock breadcrumb %} {% block content %} diff --git a/src/js/components/CameraTable.tsx b/src/js/components/CameraTable.tsx index 804c1ef5..995e105e 100644 --- a/src/js/components/CameraTable.tsx +++ b/src/js/components/CameraTable.tsx @@ -1,4 +1,4 @@ -import React, { StrictMode } from "react" +import React, { StrictMode, useRef } from "react" import RubinCalendar from "./RubinCalendar" import CurrentChannels from "./CurrentChannels" import PerDay from "./PerDay" @@ -17,23 +17,21 @@ export default function CameraTable({ isStale, seqNums, }: CameraTableProps) { - const [isClosed, setIsClosed] = React.useState(true) + const calendarRef = React.useRef(null) + const isClosed = useRef(true) function toggleCalendar() { - setIsClosed(!isClosed) - if (isClosed) { - const calendarElement = document.getElementById("calendar") - if (calendarElement && !isElementInViewport(calendarElement)) { - calendarElement.scrollIntoView() + isClosed.current = !isClosed.current + if (calendarRef.current) { + calendarRef.current.classList.toggle("closed") + if (!isClosed.current && !isElementInViewport(calendarRef.current)) { + calendarRef.current.scrollIntoView() } } } return ( -
+