diff --git a/doc/news/OSW-666.feature.rst b/doc/news/OSW-666.feature.rst new file mode 100644 index 00000000..4be84227 --- /dev/null +++ b/doc/news/OSW-666.feature.rst @@ -0,0 +1 @@ +Add a Context Feed page to Nightly Digest that uses rubin-nights to get consolidated data. \ No newline at end of file diff --git a/package.json b/package.json index 591ddbb6..c2fac68e 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "nightly-digest", "private": true, - "version": "0.8.0", - "lastUpdated": "2025-09-02", + "version": "0.9.0-alpha.1", + "lastUpdated": "2025-09-25", "type": "module", "scripts": { "dev": "vite", @@ -34,6 +34,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "js-yaml": "^4.1.0", "lucide-react": "^0.488.0", "luxon": "^3.6.1", "next-themes": "^0.4.6", diff --git a/src/assets/CopyIcon.svg b/src/assets/CopyIcon.svg new file mode 100644 index 00000000..5b38753d --- /dev/null +++ b/src/assets/CopyIcon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/FullScreenIcon.svg b/src/assets/FullScreenIcon.svg new file mode 100644 index 00000000..f240728f --- /dev/null +++ b/src/assets/FullScreenIcon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/components/ContextFeedColumns.jsx b/src/components/ContextFeedColumns.jsx new file mode 100644 index 00000000..ec14a564 --- /dev/null +++ b/src/components/ContextFeedColumns.jsx @@ -0,0 +1,419 @@ +import React from "react"; +import yaml from "js-yaml"; + +import { DateTime } from "luxon"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { createColumnHelper } from "@tanstack/react-table"; +import { formatCellValue } from "@/utils/utils"; +import { SAL_INDEX_INFO } from "@/components/context-feed-definitions.js"; + +import CopyIcon from "../assets/CopyIcon.svg"; +import FullScreenIcon from "../assets/FullScreenIcon.svg"; + +const columnHelper = createColumnHelper(); + +// TODO: Move to utils and import to here and dataLogColumns (OSW-1118) +// Exact (multiple) match(es) filter function +export const matchValueOrInList = (row, columnId, filterValue) => { + const rowValue = row.getValue(columnId); + + if (Array.isArray(filterValue)) { + return filterValue.includes(rowValue); + } + + return rowValue === filterValue; +}; + +// Helper function to make time columns more readable +// Luxon only natively supports millisecond precision, not microseconds. +// Will need to extract microseconds if this precision is required. +function formatTimestamp(tsString) { + if (!tsString) return null; + const dt = DateTime.fromISO(tsString, { zone: "utc" }); + return dt.isValid ? dt.toFormat("yyyy-LL-dd HH:mm:ss.S") : tsString; +} + +// Handles links ( tags → styled link), plain text descriptions, and +// expandable tracebacks (with copy/fullscreen). +// Expansion tracked in `expandedRows` and toggled on click. +function renderDescriptionCell(info) { + const description = info.getValue(); + if (!description) return null; + + const rowId = info.row.id; + const isTraceback = info.column.columnDef.meta?.isExpandable?.( + info.row.original, + ); + const { expandedRows, toggleExpandedRows, collapseTracebacks } = + info.table.options.meta; + const expanded = expandedRows[rowId]?.description ?? !collapseTracebacks; + + // Links to scheduler config files are passed as html. + if (description.startsWith(" + // so we split lines here to preserve break. + const lines = linkHtml.split(//i); + + return ( + // Wrap in a dark background for visibility + // when row is highlighted. +
+ + {/* Iterate over the link text lines */} + {lines.map((line, idx) => ( + + {line} + {idx < lines.length - 1 &&
} +
+ ))} +
+
+ ); + } + } catch (err) { + // Parsing failed, fallback to raw string. + console.error("Failed to parse link in description:", err); + } + } + + if (!isTraceback) return formatCellValue(description); + + // Detect consecutive spaces and move to new line + const displayDescr = expanded + ? description.replace(/( {2,})/g, "\n$1") + : description; + + // Handler for copy-to-clipboard button + const handleCopy = (e) => { + // Don’t trigger expand/collapse of entire cell + e.stopPropagation(); + navigator.clipboard.writeText(description).catch((err) => { + console.error("Copy failed:", err); + }); + }; + + return ( +
toggleExpandedRows(rowId, "description")} + > +
+        {!expanded ? (
+          // Collapsed: intro line of traceback, expandable upon click
+          <>Traceback (most recent call last): ...
+        ) : (
+          // Expanded: display full traceback
+          <>{displayDescr}
+        )}
+      
+ + {expanded && ( + // Expanded: Copy + Fullscreen buttons in top-right of cell +
+ {/* Open Traceback in Full Screen button */} + + + + + + + + + {info.row.original.event_type} + + {" - Script SAL Index "} {info.row.original.script_salIndex} + + + {info.row.original.name} @{" "} + {formatTimestamp(info.row.original.time)} + + + {/* Display the traceback with copy button in top-right */} +
+ {/* Traceback */} +
+                  {displayDescr}
+                
+ {/* Copy button */} +
+ +
+
+
+
+ {/* Copy-to-clipboard button */} + +
+ )} +
+ ); +} + +// Handles empty/null (→ null), plain non-YAML strings, and expandable YAML +// (collapsed = first line, expanded = full formatted + copy-to-clipboard). +// Expansion tracked in `expandedRows` and toggled on click. +function renderConfigCell(info) { + const config = info.getValue(); + if (!config) return null; + + const rowId = info.row.id; + const { expandedRows, collapseYaml, toggleExpandedRows } = + info.table.options.meta; + const expanded = expandedRows[rowId]?.config ?? !collapseYaml; + + // Determine if YAML + const mightBeYaml = info.column.columnDef.meta?.isExpandable?.( + info.row.original, + ); + + if (!mightBeYaml) return formatCellValue(config); + + // Apply YAML formatting if possible + let formattedConfig = config; + try { + formattedConfig = yaml.dump(yaml.load(config), { flowLevel: -1 }); + } catch (err) { + // Errors caught here aren't necessarily due to the parsing + // failing. ts-yaml will also raise an error if the yaml + // itself has a problem (e.g. duplicate keys). + // If parsing does fail, we'll fallback to raw string. + console.error("Problem with parsing config yaml:", err); + } + + // Get first line for collapsed display. + // First split into lines to check line length in collapsed mode + // for appending "..." if more than one line. + const lines = formattedConfig + .split("\n") + .filter((line) => line.trim() !== ""); + const firstLine = lines[0] || ""; + + // Handler for copy-to-clipboard button + const handleCopy = (e) => { + // Don’t trigger expand/collapse of entire cell + e.stopPropagation(); + navigator.clipboard.writeText(formattedConfig).catch((err) => { + console.error("Copy failed:", err); + }); + }; + + // Return collapsed/expanded cell + return ( +
toggleExpandedRows(rowId, "config")} + > +
+        {!expanded ? (
+          // Collapsed: first line of YAML, expandable upon click
+          // "..." appended if more lines
+          <>
+            {firstLine}
+            {lines.length > 1 && " ..."}
+          
+        ) : (
+          // Expanded: YAML
+          <>{formattedConfig}
+        )}
+      
+ + {expanded && ( + // Expanded: copy button in top-right of cell + + )} +
+ ); +} + +export const contextFeedColumns = [ + columnHelper.accessor("salIndex", { + header: "SAL Index", + cell: (info) => formatCellValue(info.getValue()), + size: 110, + filterFn: matchValueOrInList, + filterType: "string", + meta: { + tooltip: "SAL Index.", + align: "right", + }, + }), + columnHelper.accessor("event_type", { + header: "Event Type", + cell: (info) => formatCellValue(info.getValue()), + size: 200, + filterFn: matchValueOrInList, + filterType: "string", + meta: { + tooltip: "Data type displayed in the row (derived from SAL Index).", + }, + }), + columnHelper.accessor("current_task", { + header: "Current Task", + cell: (info) => formatCellValue(info.getValue()), + size: 200, + filterFn: matchValueOrInList, + filterType: "string", + meta: { + tooltip: "BLOCK or FBS configuration.", + }, + }), + columnHelper.accessor("time", { + header: "Time (UTC)", + cell: (info) => formatTimestamp(info.getValue()), + size: 220, + filterType: "number-range", + meta: { + tooltip: "Time associated with event.", + }, + }), + columnHelper.accessor("name", { + header: "Name", + cell: (info) => formatCellValue(info.getValue()), + size: 300, + filterFn: matchValueOrInList, + filterType: "string", + meta: { + tooltip: "Name of event.", + }, + }), + columnHelper.accessor("description", { + header: "Description", + size: 400, + filterFn: matchValueOrInList, + filterType: "string", + meta: { + tooltip: "Description of event or expandable error traceback.", + isExpandable: (row) => row.finalStatus === "Traceback", + }, + cell: renderDescriptionCell, + }), + columnHelper.accessor("config", { + header: "Config", + size: 350, + filterFn: matchValueOrInList, + filterType: "string", + meta: { + tooltip: "Configurations, including expandable YAML.", + isExpandable: (row) => { + const { script_salIndex, salIndex, config } = row; + return ( + script_salIndex > 0 && + [1, 2, 3].includes(salIndex) && + typeof config === "string" && + !config.startsWith("Traceback") && + config.length > 0 + ); + }, + }, + cell: renderConfigCell, + }), + columnHelper.accessor("script_salIndex", { + header: "Script SAL Index", + cell: (info) => formatCellValue(info.getValue()), + size: 150, + filterFn: matchValueOrInList, + filterType: "string", + meta: { + tooltip: "Script SAL Index.", + align: "right", + }, + }), + columnHelper.accessor("finalStatus", { + header: "Final Status", + cell: (info) => formatCellValue(info.getValue()), + size: 150, + filterFn: matchValueOrInList, + filterType: "string", + meta: { + tooltip: "Final Status.", + }, + }), + columnHelper.accessor("timestampProcessStart", { + header: "Process Start Time (UTC)", + cell: (info) => formatTimestamp(info.getValue()), + size: 220, + filterType: "number-range", + meta: { + tooltip: "Timestamp at start of process.", + }, + }), + columnHelper.accessor("timestampConfigureEnd", { + header: "Configure End Time (UTC)", + cell: (info) => formatTimestamp(info.getValue()), + size: 220, + filterType: "number-range", + meta: { + tooltip: "Timestamp at end of configuration.", + }, + }), + columnHelper.accessor("timestampRunStart", { + header: "Run Start Time (UTC)", + cell: (info) => formatTimestamp(info.getValue()), + size: 220, + filterType: "number-range", + meta: { + tooltip: "Timestamp at start of run.", + }, + }), + columnHelper.accessor("timestampProcessEnd", { + header: "Process End Time (UTC)", + cell: (info) => formatTimestamp(info.getValue()), + size: 220, + filterType: "number-range", + meta: { + tooltip: "Timestamp at end of process.", + }, + }), +]; diff --git a/src/components/ContextFeedTable.jsx b/src/components/ContextFeedTable.jsx new file mode 100644 index 00000000..25073f05 --- /dev/null +++ b/src/components/ContextFeedTable.jsx @@ -0,0 +1,557 @@ +import { useState } from "react"; + +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getGroupedRowModel, + getExpandedRowModel, + flexRender, + getFacetedMinMaxValues, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, +} from "@tanstack/react-table"; + +import { + Table, + TableHeader, + TableBody, + TableRow, + TableCell, + TableHead, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { Separator } from "./ui/separator"; + +import ColumnVisibilityPopover from "@/components/column-visibility-popover"; +import ToggleExpandCollapseRows from "@/components/toggle-expand-collapse-rows"; +import ColumnMultiSelectFilter from "@/components/column-multi-select-filter"; +import { + contextFeedColumns, + matchValueOrInList, +} from "@/components/ContextFeedColumns"; +import { SAL_INDEX_INFO } from "@/components/context-feed-definitions.js"; + +// How many skeleton rows to show when loading +const SKELETON_ROW_COUNT = 10; + +const MIN_DEFAULT_COL_WIDTH = 60; +const MAX_DEFAULT_COL_WIDTH = 500; + +const SORTING_ENUM = Object.freeze({ + ASC: false, + DESC: true, +}); + +const DEFAULT_COLUMN_VISIBILITY = { + salIndex: false, + event_type: true, + current_task: false, + time: true, + name: true, + description: true, + config: true, + script_salIndex: true, + timestampProcessStart: true, + finalStatus: true, + timestampConfigureEnd: true, + timestampRunStart: true, + timestampProcessEnd: true, +}; + +const DEFAULT_COLUMN_ORDER = [ + "salIndex", + "event_type", + "current_task", + "time", + "name", + "description", + "config", + "script_salIndex", + "timestampProcessStart", + "finalStatus", + "timestampConfigureEnd", + "timestampRunStart", + "timestampProcessEnd", +]; + +function ContextFeedTable({ + data, + dataLoading, + columnFilters, + setColumnFilters, +}) { + const [columnVisibility, setColumnVisibility] = useState( + DEFAULT_COLUMN_VISIBILITY, + ); + const [columnOrder, setColumnOrder] = useState(DEFAULT_COLUMN_ORDER); + const [sorting, setSorting] = useState([{ id: "time", desc: false }]); + const [grouping, setGrouping] = useState([]); + const [expanded, setExpanded] = useState({}); + const [expandedRows, setExpandedRows] = useState({}); + const [collapseTracebacks, setCollapseTracebacks] = useState(true); + const [collapseYaml, setCollapseYaml] = useState(true); + + // Handler for expanding/collapsing individual rows + const toggleExpandedRows = (rowId, column) => { + setExpandedRows((prev) => ({ + ...prev, + [rowId]: { + ...prev[rowId], + [column]: !prev[rowId]?.[column], + }, + })); + }; + + // Reset function + const resetTable = () => { + setColumnVisibility(DEFAULT_COLUMN_VISIBILITY); + setColumnOrder(DEFAULT_COLUMN_ORDER); + setSorting([{ id: "time", desc: false }]); + setGrouping([]); + setColumnFilters([ + { + id: "event_type", + value: Object.values(SAL_INDEX_INFO).map((info) => info.label), + }, + ]); + setCollapseTracebacks(true); + setCollapseYaml(true); + }; + + const table = useReactTable({ + data, + columns: contextFeedColumns, + state: { + columnVisibility, + columnOrder, + sorting, + grouping, + expanded, + columnFilters, + }, + meta: { + expandedRows, + toggleExpandedRows, + collapseTracebacks, + collapseYaml, + }, + filterFns: { + multiEquals: matchValueOrInList, + }, + onColumnVisibilityChange: setColumnVisibility, + onColumnOrderChange: setColumnOrder, + onSortingChange: setSorting, + onGroupingChange: setGrouping, + onExpandedChange: setExpanded, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getGroupedRowModel: getGroupedRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getFacetedMinMaxValues: getFacetedMinMaxValues(), + columnResizeMode: "onChange", + }); + + // Handler for expanding/collapsing all rows + // Used for rows with tracebacks/yaml. + const handleGlobalToggle = (columnId, value) => { + const col = table.getColumn(columnId); + if (!col) return; + + const newState = { ...expandedRows }; + + table.getRowModel().rows.forEach((row) => { + if (col.columnDef.meta?.isExpandable?.(row.original)) { + if (!newState[row.id]) newState[row.id] = {}; + // Pass the opposite of value since the checkboxes + // for tracebacks and yaml are for collapsing rows + // whereas this is tracking expanded rows. + newState[row.id][columnId] = !value; + } + }); + + setExpandedRows(newState); + }; + + return ( +
+ {/* Buttons to show/hide columns and reset table */} +
+
+ {/* Show/Hide Columns button */} + + + {/* Expand/Collapse All Groups button */} + +
+ + {/* Checkboxes for Task grouping & collapsing Tracebacks & YAML */} +
+ {/* Group/Ungroup by Task */} +
+ { + setGrouping(val ? ["current_task"] : []); + }} + style={{ borderColor: "#ffffff" }} + className="cursor-pointer" + /> + Group by Task +
+ + {/* Vertical divider */} +
+ +
+ + {/* Collapse/Expand Tracebacks */} +
+ { + setCollapseTracebacks(val); + handleGlobalToggle("description", val); + }} + style={{ borderColor: "#ffffff" }} + className="cursor-pointer" + /> + + Collapse All Tracebacks + +
+ + {/* Collapse/Expand YAML */} +
+ { + setCollapseYaml(val); + handleGlobalToggle("config", val); + }} + style={{ borderColor: "#ffffff" }} + className="cursor-pointer" + /> + + Collapse All YAML + +
+
+ + {/* Reset Button */} + +
+ + {/* Table */} +
+ {/* For sticky header */} +
+ + {/* Headers */} + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : ( + // Resizable columns +
+ {/* Header content */} + {(() => { + const tooltipText = + header.column.columnDef.meta?.tooltip; + const headerContent = ( + <> + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {/* Active sorting icons */} + {header.column.getIsSorted() === "asc" && " 🔼"} + {header.column.getIsSorted() === "desc" && + " 🔽"} + + ); + + return tooltipText ? ( +
+ + + + {headerContent} + + + + {tooltipText} + + +
+ ) : ( + headerContent + ); + })()} + + {/* Dropdown menus against each header for sorting/grouping */} + {!header.column.columnDef.columns && ( // Only show dropdown on leaf columns + + + + + + {/* Sorting */} + { + const currentSort = + header.column.getIsSorted(); + if (currentSort === false) { + header.column.toggleSorting( + SORTING_ENUM.ASC, + ); + } else if (currentSort === "asc") { + header.column.toggleSorting( + SORTING_ENUM.DESC, + ); + } else { + header.column.clearSorting(); // unsort + } + }} + > + {(() => { + const currentSort = + header.column.getIsSorted(); + if (currentSort === false) + return "Sort by asc."; + if (currentSort === "asc") + return "Sort by desc."; + if (currentSort === "desc") return "Unsort"; + return "Sort"; // fallback, shouldn't happen + })()} + + + {/* Row Grouping */} + { + header.column.toggleGrouping(); + }} + > + {header.column.getIsGrouped() + ? "Ungroup" + : "Group by"} + + + {/* Column Visibility */} + { + header.column.toggleVisibility(); + }} + > + Hide Column + + + {/* Column Filtering */} + {header.column.columnDef.filterType === + "string" && + header.column.getFacetedUniqueValues().size > + 1 && ( +
e.stopPropagation()} + > + + document.activeElement?.blur() + } + /> +
+ )} +
+
+ )} + + {/* Resize handle */} + {header.column.getCanResize() && ( +
+ )} +
+ )} + + ))} + + ))} + + + {/* Rows */} + + {dataLoading + ? Array.from({ length: SKELETON_ROW_COUNT }).map( + (_, rowIdx) => ( + + {contextFeedColumns.map((_, colIdx) => ( + + + + ))} + + ), + ) + : table.getRowModel().rows.map((row) => { + const isGroupedRow = row.getIsGrouped(); + + return ( + + {isGroupedRow ? ( + // Display rows grouped by category + +
+ {row.getIsExpanded() ? "▾" : "▸"}{" "} + { + table.getColumn(row.groupingColumnId)?.columnDef + .header + } + : {row.getValue(row.groupingColumnId) ?? "NA"} ( + {row.subRows?.length}) +
+
+ ) : ( + // Display rows normally (ungrouped) + row.getVisibleCells().map((cell) => ( + + {cell.column.id === "event_type" + ? // Render event type colourful badges + (() => { + const color = + cell.row.original.event_color || + "#ffffff"; + + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })() + : // Render remaining columns + flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + )) + )} +
+ ); + })} +
+
+
+
+
+ ); +} + +export default ContextFeedTable; diff --git a/src/components/ContextFeedTimeline.jsx b/src/components/ContextFeedTimeline.jsx new file mode 100644 index 00000000..b727fe7c --- /dev/null +++ b/src/components/ContextFeedTimeline.jsx @@ -0,0 +1,322 @@ +import { Fragment } from "react"; + +import { + Line, + LineChart, + ReferenceArea, + ReferenceLine, + ResponsiveContainer, + XAxis, + YAxis, +} from "recharts"; +import { useClickDrag } from "@/hooks/useClickDrag"; +import { + millisToDateTime, + millisToHHmm, + dayobsAtMidnight, +} from "@/utils/timeUtils"; +import { SAL_INDEX_INFO } from "@/components/context-feed-definitions.js"; + +// Small thin diamond shapes to represent events in the timeline +const CustomisedDot = ({ cx, cy, stroke, h, w, opacity = 1 }) => { + if (cx == null || cy == null) return null; + + // Defaults + const height = h || 8; + const width = w || 4; + const halfHeight = height / 2; + const halfWidth = width / 2; + const fill = stroke || "#3CAE3F"; + + // Points for the diamond: top, right, bottom, left + const points = ` + ${halfWidth},0 + ${width},${halfHeight} + ${halfWidth},${height} + 0,${halfHeight} + `; + + return ( + + + + ); +}; + +function ContextFeedTimeline({ + data, + twilightValues, + fullTimeRange, + selectedTimeRange, + setSelectedTimeRange, + columnFilters, +}) { + // Click & Drag plot hooks ================================ + const { + refAreaLeft, + refAreaRight, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleDoubleClick, + } = useClickDrag(setSelectedTimeRange, fullTimeRange); + // -------------------------------------------------------- + + // Convert datetime inputs to millis format for plots ==== + const xMinMillis = fullTimeRange[0]?.toMillis(); + const xMaxMillis = fullTimeRange[1]?.toMillis(); + const selectedMinMillis = selectedTimeRange[0]?.toMillis(); + const selectedMaxMillis = selectedTimeRange[1]?.toMillis(); + + if (!xMinMillis || !xMaxMillis) return null; + // -------------------------------------------------------- + + // Axis Utils ============================================= + // TAI xAxis ticks + const generateHourlyTicks = (startMillis, endMillis, intervalHours = 1) => { + const ticks = []; + + // Get start of first hour and end of last hour on timeline + let t = millisToDateTime(startMillis).startOf("hour"); + const endDt = millisToDateTime(endMillis).endOf("hour"); + + // Loop through timeline, collecting hours + while (t <= endDt) { + ticks.push(t.toMillis()); + t = t.plus({ hours: intervalHours }); + } + return ticks; + }; + const hourlyTicks = generateHourlyTicks(xMinMillis, xMaxMillis, 1); + + // Dayobs labels along xAxis + // Show vertical lines at midday dayobs borders + // Show dayobs labels at midnight (UTC) + const renderDayobsTicks = ({ x, y, payload }) => { + const { value } = payload; + const dt = millisToDateTime(value); + + const hourUTC = dt.hour; + const isMiddayUTC = hourUTC === 12; + const isMidnightUTC = hourUTC === 0; + + const DIST_BELOW_xAXIS = 10; + const LABEL_TEXT_SIZE = 16; + + // Lines at midday dayobs borders + if (isMiddayUTC) { + return ( + + ); + } + + // Dayobs labels + if (isMidnightUTC) { + // Get date prior to midnight + const dayobsLabel = dayobsAtMidnight(dt, "yyyy-LL-dd"); + + return ( + <> + + {dayobsLabel} + + + ); + } + + return null; + }; + + // Extract active labels from filters to be used for opacity + // of event markers in timeline. + const activeLabels = + columnFilters.find((f) => f.id === "event_type")?.value ?? []; + + const PLOT_HEIGHT = 250; + const PLOT_LABEL_HEIGHT = 20; // height of the xAxis label + // -------------------------------------------------------- + + // Timeline =============================================== + return ( + + + {/* Timeline */} + + {/* Vertical lines on the hour */} + {hourlyTicks.map((tick) => ( + + ))} + {/* Dayobs labels and lines */} + {twilightValues.length > 1 && ( + + )} + {/* TAI Time Axis */} + + {/* Fixed domain; 9 event types plotted at integer indices */} + + {/* Twilight Lines and Times */} + {twilightValues.map((twi, i) => + xMinMillis <= twi && twi <= xMaxMillis ? ( + + ) : null, + )} + {/* Selection Area (shaded background) shown once time window selection made */} + {selectedMinMillis && selectedMaxMillis ? ( + + ) : null} + {/* Data Points & Lines */} + {Object.values(SAL_INDEX_INFO) + .filter( + (info) => + info.displayIndex != null && + info.label !== "AUTOLOG" && + info.label !== "Narrative Log", + ) + .map((info) => { + const { displayIndex, label, color } = info; + const isActive = + activeLabels.length === 0 || activeLabels.includes(label); + const opacity = isActive ? 1 : 0.1; + + return ( + + {/* Horizontal lines behind data points */} + + {/* Data points */} + d.displayIndex === displayIndex) + .map((d) => ({ ...d, y: 10 - displayIndex }))} + dataKey="y" + stroke="" + dot={(props) => { + const { key } = props; + return ( + + ); + }} + isAnimationActive={false} + /> + + ); + })} + {/* Selection area (rectangle outline) shown once time window selection made */} + {selectedMinMillis > 0 && selectedMaxMillis > 0 && ( + + )} + {/* Selection rectangle shown during active highlighting */} + {refAreaLeft > 0 && refAreaRight > 0 && ( + + )} + + + ); +} + +export default ContextFeedTimeline; diff --git a/src/components/EditableDateTimeInput.jsx b/src/components/EditableDateTimeInput.jsx new file mode 100644 index 00000000..c03aa0c7 --- /dev/null +++ b/src/components/EditableDateTimeInput.jsx @@ -0,0 +1,68 @@ +import { useState, useEffect } from "react"; +import { DateTime } from "luxon"; + +import { Input } from "@/components/ui/input"; + +function EditableDateTimeInput({ + value, + onValidChange, + fullTimeRange, + otherBound, // the "other" DateTime (start or end) + isStart = false, // true if this is the start input +}) { + const [draft, setDraft] = useState(value.toFormat("HH:mm yyyy-LL-dd")); + + // Keep draft synced with external values + useEffect(() => { + setDraft(value.toFormat("HH:mm yyyy-LL-dd")); + }, [value]); + + // Validation checks + const dt = DateTime.fromFormat(draft, "HH:mm yyyy-LL-dd", { zone: "utc" }); + const inRange = + dt.isValid && dt >= fullTimeRange[0] && dt <= fullTimeRange[1]; + + // Check start < end + let crossValid = true; + if (dt.isValid && otherBound) { + crossValid = isStart ? dt < otherBound : dt > otherBound; + } + + const isValid = dt.isValid && inRange && crossValid; + + // Commit the change only if input is valid + const tryCommit = () => { + if (isValid) { + onValidChange(dt); + setDraft(dt.toFormat("HH:mm yyyy-LL-dd")); + } else { + setDraft(value.toFormat("HH:mm yyyy-LL-dd")); // revert + } + }; + + return ( + setDraft(e.target.value)} + onBlur={tryCommit} + onKeyDown={(e) => { + if (e.key === "Enter") { + tryCommit(); + e.target.blur(); + } + if (e.key === "Escape") { + setDraft(value.toFormat("HH:mm yyyy-LL-dd")); // revert + e.target.blur(); + } + }} + className={`w-[150px] bg-teal-800 text-center font-normal text-[12px] text-white rounded-md shadow-[2px_2px_2px_0px_#3CAE3F] focus-visible:ring-4 ${ + isValid + ? "text-white border-none focus-visible:ring-green-500/50" + : "text-white border border-2 border-red-500 focus-visible:ring-red-500/50" + }`} + /> + ); +} + +export default EditableDateTimeInput; diff --git a/src/components/column-multi-select-filter.jsx b/src/components/column-multi-select-filter.jsx index fd4644c5..8f11d702 100644 --- a/src/components/column-multi-select-filter.jsx +++ b/src/components/column-multi-select-filter.jsx @@ -44,22 +44,24 @@ function ColumnMultiSelectFilter({ column, closeDropdown }) {

Filter:

{/* Scrollable multi-select checkboxes */} -
+
{sortedUniqueValues.map((value) => ( ))}
- {/* TODO: Only show once data has loaded? */} {/* Clear and Apply buttons */}
@@ -46,7 +48,7 @@ function ColumnVisibilityPopover({ table }) { Deselect All
-
+
{allColumns.map((column) => (
Event Type labels, display order and colour mappings +// Bright rainbow colours +export const SAL_INDEX_INFO = { + 0: { color: "#ffffff", label: "Narrative Log", displayIndex: null }, // not plotted + 1: { color: "#eab308", label: "MT Queue", displayIndex: 5 }, + 2: { color: "#22c55e", label: "AT Queue", displayIndex: 6 }, + 3: { color: "#f97316", label: "OCS Queue", displayIndex: 4 }, + 4: { color: "#e41a1c", label: "EFD Error", displayIndex: 3 }, + 5: { color: "#a855f7", label: "Simonyi Exposure", displayIndex: 1 }, + 6: { color: "#f472b6", label: "AuxTel Exposure", displayIndex: 2 }, + 7: { color: "#38bdf8", label: "Narrative Log (AuxTel)", displayIndex: 8 }, + 8: { color: "#14b8a6", label: "Narrative Log (Simonyi)", displayIndex: 7 }, + 9: { color: "#3b82f6", label: "Narrative Log (General)", displayIndex: 9 }, + 10: { color: "#ffffff", label: "AUTOLOG", displayIndex: 10 }, +}; + +// Attempt to group telescope events into colourblind friendly colour groups +// Not quite right yet and looks ugly! +// export const SAL_INDEX_INFO = { +// 0: { color: "#dcdcdc", label: "Narrative Log", displayIndex: null }, // not plotted +// 1: { color: "#ffbb78", label: "MT Queue", displayIndex: 5 }, +// 2: { color: "#aec7e8", label: "AT Queue", displayIndex: 6 }, +// 3: { color: "#bdbdbd", label: "OCS Queue", displayIndex: 4 }, +// 4: { color: "#7f7f7f", label: "EFD Error", displayIndex: 3 }, +// 5: { color: "#ff7f0e", label: "Simonyi Exposure", displayIndex: 1 }, +// 6: { color: "#1f77b4", label: "AuxTel Exposure", displayIndex: 2 }, +// 7: { color: "#c6dbef", label: "Narrative Log (AuxTel)", displayIndex: 8 }, +// 8: { color: "#f7b6d1", label: "Narrative Log (Simonyi)", displayIndex: 7 }, +// 9: { color: "#d9d9d9", label: "Narrative Log (General)", displayIndex: 9 }, +// 10: { color: "#ffffff", label: "AUTOLOG", displayIndex: 10 }, +// }; diff --git a/src/components/toggle-expand-collapse-rows.jsx b/src/components/toggle-expand-collapse-rows.jsx index bcf73072..1cb833d9 100644 --- a/src/components/toggle-expand-collapse-rows.jsx +++ b/src/components/toggle-expand-collapse-rows.jsx @@ -1,6 +1,6 @@ import React from "react"; -function ToggleExpandCollapseRows({ table, expanded, setExpanded }) { +function ToggleExpandCollapseRows({ table, expanded, setExpanded, variant }) { const grouping = table.getState().grouping; // Walk grouped row model recursively to collect @@ -36,11 +36,16 @@ function ToggleExpandCollapseRows({ table, expanded, setExpanded }) { } }} disabled={grouping.length === 0} - className={`btn bg-white text-black mt-4 h-10 font-light rounded-md shadow-[4px_4px_4px_0px_#3CAE3F] flex justify-center items-center py-2 px-4 ${ - grouping.length === 0 - ? "bg-gray-200 text-gray-400 cursor-not-allowed" - : "bg-white text-black hover:shadow-[6px_6px_8px_0px_#3CAE3F] hover:scale-[1.02] hover:bg-white transition-all duration-200" - }`} + className={ + "btn h-10 rounded-md " + + (variant === "teal" + ? grouping.length === 0 + ? "cursor-not-allowed w-[150px] justify-between self-end font-normal text-[12px] bg-stone-700 text-stone-400 border-2 border-stone-400 shadow-[4px_4px_4px_0px_#52525b]" + : "cursor-pointer w-[150px] justify-between self-end font-normal text-[12px] bg-teal-800 text-white border-2 border-white shadow-[4px_4px_4px_0px_#3CAE3F] focus-visible:ring-4 focus-visible:ring-green-500/50 hover:shadow-[6px_6px_8px_0px_#3CAE3F] hover:scale-[1.02] hover:bg-teal-700 transition-all duration-200" + : grouping.length === 0 + ? "cursor-not-allowed flex justify-center items-center py-2 px-4 font-light mt-4 bg-gray-200 text-gray-400 border-2 border-gray-300 shadow-[4px_4px_4px_0px_#a8a29e]" + : "cursor-pointer flex justify-center items-center py-2 px-4 font-light mt-4 bg-white text-black border-2 border-white shadow-[4px_4px_4px_0px_#3CAE3F] hover:shadow-[6px_6px_8px_0px_#3CAE3F] hover:scale-[1.02] hover:bg-white transition-all duration-200") + } > {allExpanded ? "Collapse All Groups" : "Expand All Groups"} diff --git a/src/components/ui/table.jsx b/src/components/ui/table.jsx index ca0fd4bd..0d69fbd1 100644 --- a/src/components/ui/table.jsx +++ b/src/components/ui/table.jsx @@ -55,7 +55,7 @@ function TableRow({ className, ...props }) { { + const eventTypes = Object.values(SAL_INDEX_INFO).map((info) => info.label); + + // Define which labels to exclude per telescope + const exclusions = { + Simonyi: ["AT Queue", "AuxTel Exposure", "Narrative Log (AuxTel)"], + AuxTel: ["MT Queue", "Simonyi Exposure", "Narrative Log (Simonyi)"], + }; + + if (telescope && exclusions[telescope]) { + return eventTypes.filter((label) => !exclusions[telescope].includes(label)); + } + + return eventTypes; +}; + +function ContextFeed() { + // Subscribe component to URL params + const { startDayobs, endDayobs, telescope, startTime, endTime } = useSearch({ + from: "/context-feed", + }); + + // Our dayobs inputs are inclusive, so we add one day to the + // endDayobs to get the correct range for the queries + // (which are exclusive of the end date). + const queryEndDayobs = getDatetimeFromDayobsStr(endDayobs.toString()) + .plus({ days: 1 }) + .toFormat("yyyyMMdd"); + + // Time ranges for timeline + // fullTimeRange doesn't need to be stored in state because it + // isn't modified by any child components, whereas selectedTimeRange + // is modified by the timeline. + const fullTimeRange = useMemo( + () => [ + getDayobsStartUTC(String(startDayobs)), + getDayobsEndUTC(String(endDayobs)), + ], + [startDayobs, endDayobs], + ); + + // Set selected times to match url times, if valid times provided, + // else fallback to full dayobs range. + const [selectedTimeRange, setSelectedTimeRange] = useState(() => { + const startMillis = Number(startTime); + const endMillis = Number(endTime); + return getValidTimeRange(startMillis, endMillis, fullTimeRange); + }); + + const [contextFeedData, setContextFeedData] = useState([]); + const [contextFeedLoading, setContextFeedLoading] = useState(true); + const [twilightValues, setTwilightValues] = useState([]); + const [almanacLoading, setAlmanacLoading] = useState(true); + const [timelineVisible, setTimelineVisible] = useState(true); + const [tipsVisible, setTipsVisible] = useState(true); + + // Timeline checkbox state is stored in the Table's columnFilters state. + // While all other table state is kept inside ContextFeedTable.jsx, + // This state is kept here so that the timeline checkboxes can update it. + const [columnFilters, setColumnFilters] = useState([ + { + id: "event_type", + value: filterDefaultEventsByTelescope(telescope), + }, + ]); + + // Nav & search param hooks + const navigate = useNavigate({ from: "/context-feed" }); + const searchParams = useSearch({ from: "/context-feed" }); + + // Update the "event_type" filter whenever telescope changes + useEffect(() => { + setColumnFilters((prevFilters) => { + // Keep other filters + const otherFilters = prevFilters.filter((f) => f.id !== "event_type"); + + return [ + ...otherFilters, + { + id: "event_type", + value: filterDefaultEventsByTelescope(telescope), + }, + ]; + }); + }, [telescope]); + + // Set selectedTimeRange from url params. + // Must be separate from general url-filter sync to avoid infinite renders. + useEffect(() => { + const startMillis = Number(startTime); + const endMillis = Number(endTime); + const newRange = getValidTimeRange(startMillis, endMillis, fullTimeRange); + + if ( + newRange[0].toMillis() !== selectedTimeRange[0].toMillis() || + newRange[1].toMillis() !== selectedTimeRange[1].toMillis() + ) { + setSelectedTimeRange(newRange); + } + }, [startTime, endTime, fullTimeRange]); + + // Sync url time params with selectedTimeRange + useEffect(() => { + if (!selectedTimeRange[0] || !selectedTimeRange[1]) return; + + // Compare current url params with selectedTimeRange + // Update url params if they differ + const currentStart = Number(searchParams.startTime); + const currentEnd = Number(searchParams.endTime); + + const newStart = selectedTimeRange[0].toMillis(); + const newEnd = selectedTimeRange[1].toMillis(); + + if (currentStart !== newStart || currentEnd !== newEnd) { + // Mutate browser history with new times + navigate({ + to: "/context-feed", + search: { + ...searchParams, + startTime: newStart, + endTime: newEnd, + }, + replace: true, + }); + } + }, [selectedTimeRange]); + + // Currently unchanged from Plots version. + // If remains unchanged, move to and import from utils + function prepareAlmanacData(almanac) { + // Set values for twilight lines + const twilightValues = almanac + .map((dayobsAlm) => [ + utcDateTimeStrToMillis(dayobsAlm.twilight_evening), + utcDateTimeStrToMillis(dayobsAlm.twilight_morning), + ]) + .flat(); + + setTwilightValues(twilightValues); + } + + // Handler for timeline checkboxes (eventType filters) + const toggleEvents = (key, checked) => { + setColumnFilters((prev) => { + // Determine currently selected event types + const prevFilter = prev.find((f) => f.id === "event_type"); + let selectedEventKeys = prevFilter + ? prevFilter.value + .map( + (label) => + // Map label back to key + Object.entries(SAL_INDEX_INFO).find( + ([, info]) => info.label === label, + )?.[0], + ) + .filter(Boolean) + : Object.keys(SAL_INDEX_INFO); + + // Update selected keys based on the checkbox toggle + selectedEventKeys = checked + ? [...selectedEventKeys, key] + : selectedEventKeys.filter((k) => k !== key); + + // Build new columnFilters object for TanStack Table + return [ + { + id: "event_type", + value: + selectedEventKeys.length > 0 + ? selectedEventKeys.map((k) => SAL_INDEX_INFO[k].label) + : Object.values(SAL_INDEX_INFO).map((info) => info.label), // all==none + }, + ]; + }); + }; + + useEffect(() => { + // In case we need to cancel a fetch + const abortController = new AbortController(); + + setContextFeedLoading(true); + setAlmanacLoading(true); + + fetchAlmanac(startDayobs, queryEndDayobs, abortController) + .then((almanac) => { + if (almanac === null) { + toast.warning( + "No almanac data available. Plots will be displayed without accompanying almanac information.", + ); + } else { + prepareAlmanacData(almanac); + } + }) + .catch((err) => { + if (!abortController.signal.aborted) { + toast.error("Error fetching almanac!", { + description: err?.message, + duration: Infinity, + }); + } + }) + .finally(() => { + if (!abortController.signal.aborted) { + setAlmanacLoading(false); + } + }); + + fetchContextFeed(startDayobs, endDayobs, abortController) + .then(([data]) => { + let currentTask = null; + + const preparedData = data + .map((entry) => { + let salInfo = SAL_INDEX_INFO[entry.salIndex] || {}; + let displayIndex = salInfo.displayIndex; + let eventType = salInfo.label; + let eventColor = salInfo.color ?? "#ffffff"; + + // Split Narrative Log into variants + // TODO: Adapt when this comes from rubin_nights (OSW-1119) + if (salInfo.label === "Narrative Log") { + if (NARRATIVE_LOG_MAP.AuxTel.includes(entry.name)) { + salInfo = SAL_INDEX_INFO[7]; + } else if (NARRATIVE_LOG_MAP.Simonyi.includes(entry.name)) { + salInfo = SAL_INDEX_INFO[8]; + } else { + salInfo = SAL_INDEX_INFO[9]; + } + displayIndex = salInfo.displayIndex; + eventType = salInfo.label; + eventColor = salInfo.color; + } + + if ( + // TODO: swap to "Task Change" (OSW-1119) + entry.finalStatus === "Job Change" + ) { + currentTask = entry.name; + } + + return { + ...entry, + event_time_dt: isoToUTC(entry["time"]), + event_time_millis: isoToUTC(entry["time"]).toMillis(), + event_type: eventType, + event_color: eventColor, + displayIndex, + current_task: currentTask, + }; + }) + // Chronological order + .sort((a, b) => a.event_time_millis - b.event_time_millis); + + setContextFeedData(preparedData); + + if (data.length === 0) { + toast.warning("No Context Feed entries found in the date range."); + } + }) + .catch((err) => { + // If the error is not caused by the fetch being aborted + // then toast the error message. + if (!abortController.signal.aborted) { + const msg = err?.message; + toast.error("Error fetching Context Feed data!", { + description: msg, + duration: Infinity, + }); + } + }) + .finally(() => { + if (!abortController.signal.aborted) { + setContextFeedLoading(false); + } + }); + + return () => { + abortController.abort(); + }; + }, [startDayobs, endDayobs, telescope]); + + // Filter data based on selected time range + // and the event types selected by checkboxes + const filteredData = contextFeedData.filter( + (entry) => + entry.event_time_dt >= selectedTimeRange[0] && + entry.event_time_dt <= selectedTimeRange[1], + ); + + return ( + <> +
+ {/* Page Header, Timeline & Tips Banners */} + + {/* Page title + buttons */} + + + Context Feed: + + Chronologically ordered log of exposures, scripts, errors and + narrations. + + + {/* Buttons - Download, Show/Hide Timeline, Show/Hide Tips */} +
+
+ + + + + + This is a placeholder for the download/export button. Once + implemented, clicking here will download the data shown in + the table to a .csv file. + + + {/* Button to toggle timeline visibility */} + + {/* Button to toggle tips visibility */} + +
+
+
+ + {/* Timeline Tips */} + {tipsVisible && ( + +
+ 💡 +

+ Timeline Tips +

+
    +
  • Click + drag to select a time range.
  • +
  • Double-click to reset to the selected dayobs range.
  • +
  • The table updates automatically to match.
  • +
+
+
+ )} + + {/* Timeline */} + {timelineVisible && ( + + {contextFeedLoading || almanacLoading ? ( + + ) : ( +
+ {/* Event Type Checkboxes */} +
+ {Object.entries(SAL_INDEX_INFO) + .filter(([key]) => key !== "10" && key !== "0") // exclude AUTOLOG & NL + .sort( + // sort on displayIndex + ([, aInfo], [, bInfo]) => + (aInfo.displayIndex ?? 0) - (bInfo.displayIndex ?? 0), + ) + .map(([key, info]) => { + // Determine if checkbox should be checked + const checked = columnFilters + .find((f) => f.id === "event_type") + ?.value.includes(info.label); + return ( +
+ + toggleEvents(key, !!checked) + } + style={{ borderColor: info.color }} + /> + + {info.label} + +
+ ); + })} +
+ +
+ )} +
+ )} + + {/* Editable Time Range */} + {selectedTimeRange[0] && selectedTimeRange[1] && ( + + {/* Left label */} + + Selected Time Range (UTC): + + + {/* Centered inputs */} + + {/* Start DateTime */} + + setSelectedTimeRange([dt, selectedTimeRange[1]]) + } + fullTimeRange={fullTimeRange} + otherBound={selectedTimeRange[1]} + isStart={true} + /> + - + {/* End DateTime */} + + setSelectedTimeRange([selectedTimeRange[0], dt]) + } + fullTimeRange={fullTimeRange} + otherBound={selectedTimeRange[0]} + isStart={false} + /> + + + )} + + {/* Table Tips */} + {tipsVisible && ( + +
+ 💡 +

Table Tips

+
    +
  • + Collapse/expand tracebacks & YAMLs by clicking cells or + using checkboxes. +
  • +
  • + Use the + + {" ⋮ "} + + menu in column headers for sorting, grouping, filtering, or + hiding. +
  • +
+
+
+ )} +
+ + {/* Table */} + +
+ {/* Error / warning / info message pop-ups */} + + + ); +} + +export default ContextFeed; diff --git a/src/pages/context-feed.jsx b/src/pages/context-feed.jsx deleted file mode 100644 index a763b54a..00000000 --- a/src/pages/context-feed.jsx +++ /dev/null @@ -1,40 +0,0 @@ -// import { useRouter } from "@tanstack/react-router"; -import React from "react"; -import { useSearch } from "@tanstack/react-router"; - -function ContextFeed() { - const { startDayobs, endDayobs } = useSearch({ - from: "__root__", - }); - - return ( -
-
- {/* Page Title */} -

- Context - Feed -

- - {/* Link Section */} -
- This page is currently in work. Please see the{" "} - - Script Queue Miner Notebook - - . -
- If you have any feature requests for this page, please contact the - logging team. -
-
-
- ); -} - -export default ContextFeed; diff --git a/src/routes.js b/src/routes.js index 3a6dd0d3..3472a013 100644 --- a/src/routes.js +++ b/src/routes.js @@ -5,7 +5,7 @@ import { } from "@tanstack/react-router"; import Layout from "./pages/layout"; import DataLog from "./pages/data-log"; -import ContextFeed from "./pages/context-feed"; +import ContextFeed from "./pages/ContextFeed"; import Digest from "./pages/digest"; import Plots from "./pages/plots"; import { z } from "zod"; diff --git a/src/utils/fetchUtils.jsx b/src/utils/fetchUtils.jsx index 54570275..8be906c5 100644 --- a/src/utils/fetchUtils.jsx +++ b/src/utils/fetchUtils.jsx @@ -296,6 +296,32 @@ const fetchDataLogEntriesFromExposureLog = async ( } }; +/** + * Fetches the context feed data for a specified date range. + * + * @async + * @function fetchContextFeed + * @param {string} start - The start date for the observation range (format: YYYYMMDD). + * @param {string} end - The end date for the observation range (format: YYYYMMDD). + * @param {AbortController} abortController - The AbortController used to cancel the request if needed. + * @returns {Promise} A promise that resolves to an array of objects with: + * - efd_and_messages (Pandas dataframe): A Dataframe of relevant logging and EFD messages. + * - cols (string[]): The short-list of columns for display in the table. + * @throws {Error} Throws an error if the context feed data cannot be fetched and the request was not aborted. + */ +const fetchContextFeed = async (start, end, abortController) => { + const url = `${backendLocation}/context-feed?dayObsStart=${start}&dayObsEnd=${end}`; + try { + const data = await fetchData(url, abortController); + return [data.data, data.cols]; + } catch (err) { + if (err.name !== "AbortError") { + console.error("Error fetching ContextFeed API:", err); + } + throw err; + } +}; + export { fetchExposures, fetchAlmanac, @@ -305,4 +331,5 @@ export { fetchJiraTickets, fetchDataLogEntriesFromConsDB, fetchDataLogEntriesFromExposureLog, + fetchContextFeed, }; diff --git a/src/utils/timeUtils.jsx b/src/utils/timeUtils.jsx index 54cf45af..a6e16ec9 100644 --- a/src/utils/timeUtils.jsx +++ b/src/utils/timeUtils.jsx @@ -16,6 +16,14 @@ const isoToTAI = (isoStr) => seconds: TAI_OFFSET_SECONDS, }); +/** + * Converts an ISO 8601 string to a Luxon DateTime object in UTC. + * + * @param {string} isoStr - The ISO date-time string (e.g., "2025-08-27T12:34:56Z"). + * @returns {DateTime} A Luxon DateTime object in UTC. + */ +const isoToUTC = (isoStr) => DateTime.fromISO(isoStr, { zone: "utc" }); + /** * Converts a dayobs string to the "start" boundary (12:00 noon) in TAI. * @@ -32,6 +40,8 @@ const getDayobsStartTAI = (dayobsStr) => { /** * Converts a dayobs string to the "end" boundary (11:59 AM next day) in TAI. * + * Each dayobs ends at midday UTC the next day, so we add a day and return that midday. + * * @param {string} dayobsStr - The dayobs date string in "yyyyLLdd" format (e.g., "20250827"). * @returns {DateTime} A Luxon DateTime object in UTC with TAI offset seconds applied. */ @@ -44,6 +54,34 @@ const getDayobsEndTAI = (dayobsStr) => { .set({ hour: 11, minute: 59, second: TAI_OFFSET_SECONDS }); }; +/** + * Converts a dayobs string to the "start" boundary (12:00 noon) in UTC. + * + * @param {string} dayobsStr - The dayobs date string in "yyyyLLdd" format (e.g., "20250827"). + * @returns {DateTime} A Luxon DateTime object in UTC at 12:00:00. + */ +const getDayobsStartUTC = (dayobsStr) => { + const dayobsDate = DateTime.fromFormat(dayobsStr, "yyyyLLdd", { + zone: "utc", + }); + return dayobsDate.set({ hour: 12, minute: 0, second: 0 }); +}; + +/** + * Converts a dayobs string to the "end" boundary (11:59:59 AM next day) in UTC. + * + * Each dayobs ends at midday UTC the next day, so we add a day and return that midday. + * + * @param {string} dayobsStr - The dayobs date string in "yyyyLLdd" format (e.g., "20250827"). + * @returns {DateTime} A Luxon DateTime object in UTC at 11:59:59 next day. + */ +const getDayobsEndUTC = (dayobsStr) => { + const dayobsDate = DateTime.fromFormat(dayobsStr, "yyyyLLdd", { + zone: "utc", + }); + return dayobsDate.plus({ days: 1 }).set({ hour: 11, minute: 59, second: 59 }); +}; + /** * Adjusts an almanac-provided dayobs number to the plotting convention. * @@ -96,6 +134,18 @@ const utcDateTimeStrToTAIMillis = (dateTimeStr) => .plus({ seconds: TAI_OFFSET_SECONDS }) .toMillis(); +/** + * Converts a UTC date-time string in "yyyy-MM-dd HH:mm:ss" format + * to milliseconds since the epoch. + * + * @param {string} dateTimeStr - The UTC date-time string (e.g., "2025-08-27 15:42:00"). + * @returns {number} The corresponding timestamp in milliseconds. + */ +const utcDateTimeStrToMillis = (dateTimeStr) => + DateTime.fromFormat(dateTimeStr, "yyyy-MM-dd HH:mm:ss", { + zone: "utc", + }).toMillis(); + /** * Converts a Luxon DateTime to a formatted dayobs string, * subtracting 1 minute to capture the previous day's date. @@ -110,15 +160,47 @@ const dayobsAtMidnight = (dt, format = "yyyy-LL-dd") => { return dt.minus({ minutes: 1 }).toFormat(format); }; +/** + * Validate start/end millis and convert to Luxon DateTime objects if valid. + * Otherwise return the provided fullTimeRange. + * + * @param {number} startMillis + * @param {number} endMillis + * @param {[DateTime, DateTime]} fullTimeRange + * @returns {[DateTime, DateTime]} start/end DateTimes + */ +function getValidTimeRange(startMillis, endMillis, fullTimeRange) { + if ( + startMillis != null && + endMillis != null && + !Number.isNaN(startMillis) && + !Number.isNaN(endMillis) + ) { + const [fullStart, fullEnd] = fullTimeRange; + if ( + startMillis >= fullStart.toMillis() && + endMillis <= fullEnd.toMillis() + ) { + return [millisToDateTime(startMillis), millisToDateTime(endMillis)]; + } + } + return fullTimeRange; +} + export { isoToTAI, + isoToUTC, getDayobsStartTAI, getDayobsEndTAI, + getDayobsStartUTC, + getDayobsEndUTC, almanacDayobsForPlot, millisToDateTime, millisToHHmm, utcDateTimeStrToTAIMillis, + utcDateTimeStrToMillis, dayobsAtMidnight, + getValidTimeRange, ISO_DATETIME_FORMAT, TAI_OFFSET_SECONDS, }; diff --git a/tests/timeUtils.test.js b/tests/timeUtils.test.js index 6cae5ad4..335a93ba 100644 --- a/tests/timeUtils.test.js +++ b/tests/timeUtils.test.js @@ -3,13 +3,18 @@ import { describe, it, expect } from "vitest"; import { DateTime } from "luxon"; import { isoToTAI, + isoToUTC, getDayobsStartTAI, getDayobsEndTAI, + getDayobsStartUTC, + getDayobsEndUTC, almanacDayobsForPlot, millisToDateTime, millisToHHmm, utcDateTimeStrToTAIMillis, + utcDateTimeStrToMillis, dayobsAtMidnight, + getValidTimeRange, } from "../src/utils/timeUtils"; const TAI_OFFSET_SECONDS = 37; @@ -22,6 +27,13 @@ describe("timeUtils", () => { }); }); + describe("isoToUTC", () => { + it("parses ISO string without TAI offset", () => { + const dt = isoToUTC("2025-08-18T00:00:00Z"); + expect(dt.toISO()).toBe("2025-08-18T00:00:00.000Z"); + }); + }); + describe("getDayobsStartTAI", () => { it("returns 12:00 noon TAI for the given dayobs", () => { const dt = getDayobsStartTAI("20250818"); @@ -36,6 +48,20 @@ describe("timeUtils", () => { }); }); + describe("getDayobsStartUTC", () => { + it("returns 12:00 noon UTC for the given dayobs", () => { + const dt = getDayobsStartUTC("20250818"); + expect(dt.toISO()).toBe("2025-08-18T12:00:00.000Z"); + }); + }); + + describe("getDayobsEndUTC", () => { + it("returns 11:59 next day UTC for the given dayobs", () => { + const dt = getDayobsEndUTC("20250818"); + expect(dt.toISO()).toBe("2025-08-19T11:59:59.000Z"); + }); + }); + describe("almanacDayobsForPlot", () => { it("subtracts one day from the input dayobs", () => { const result = almanacDayobsForPlot(20250818); @@ -43,6 +69,10 @@ describe("timeUtils", () => { }); }); + // Note: for the following tests that use JS Date library, + // due to historical reasons, month is 0-indexed. + // e.g. Aug = 7, not 8 + describe("millisToDateTime", () => { it("converts millis to UTC DateTime", () => { const millis = Date.UTC(2025, 7, 18, 0, 0, 0); // Aug 18, 2025 00:00:00 UTC @@ -66,6 +96,14 @@ describe("timeUtils", () => { }); }); + describe("utcDateTimeStrToMillis", () => { + it("parses string to millis without TAI offset", () => { + const result = utcDateTimeStrToMillis("2025-08-18 00:00:00"); + const expected = Date.UTC(2025, 7, 18, 0, 0, 0); // Aug = 7 + expect(result).toBe(expected); + }); + }); + describe("dayobsAtMidnight", () => { it("defaults to yyyy-LL-dd format", () => { const dt = DateTime.fromISO("2025-08-18T00:00:00Z", { zone: "utc" }); @@ -77,4 +115,50 @@ describe("timeUtils", () => { expect(dayobsAtMidnight(dt, "yyyyLLdd")).toBe("20250817"); }); }); + + describe("getValidTimeRange", () => { + const fullTimeRange = [ + getDayobsStartUTC("20250818"), + getDayobsEndUTC("20250818"), + ]; + + it("returns start/end DateTimes if millis are valid and in range", () => { + const startMillis = + getDayobsStartUTC("20250818").toMillis() + 6 * 3600 * 1000; // +6h + const endMillis = + getDayobsStartUTC("20250818").toMillis() + 18 * 3600 * 1000; // +18h + + const result = getValidTimeRange(startMillis, endMillis, fullTimeRange); + + expect(result[0].toMillis()).toBe(startMillis); + expect(result[1].toMillis()).toBe(endMillis); + }); + + it("returns fullTimeRange if startMillis is null", () => { + const endMillis = fullTimeRange[1].toMillis(); + const result = getValidTimeRange(null, endMillis, fullTimeRange); + expect(result).toEqual(fullTimeRange); + }); + + it("returns fullTimeRange if endMillis is NaN", () => { + const startMillis = fullTimeRange[0].toMillis(); + const result = getValidTimeRange(startMillis, NaN, fullTimeRange); + expect(result).toEqual(fullTimeRange); + }); + + it("returns fullTimeRange if times are out of range", () => { + const startMillis = fullTimeRange[0].toMillis() - 3600 * 1000; // before range + const endMillis = fullTimeRange[1].toMillis() + 3600 * 1000; // after range + + const result = getValidTimeRange(startMillis, endMillis, fullTimeRange); + expect(result).toEqual(fullTimeRange); + }); + + it("returns fullTimeRange if times partially out of range", () => { + const startMillis = fullTimeRange[0].toMillis() + 2 * 3600 * 1000; // in range + const endMillis = fullTimeRange[1].toMillis() + 3600 * 1000; // out of range + const result = getValidTimeRange(startMillis, endMillis, fullTimeRange); + expect(result).toEqual(fullTimeRange); + }); + }); });