diff --git a/apps/lrauv-dash2/components/DeploymentMap.tsx b/apps/lrauv-dash2/components/DeploymentMap.tsx index 25ea7796..bf8c8556 100644 --- a/apps/lrauv-dash2/components/DeploymentMap.tsx +++ b/apps/lrauv-dash2/components/DeploymentMap.tsx @@ -12,6 +12,7 @@ import { createLogger } from '@mbari/utils' import VehicleColorsModal from './VehicleColorsModal' import useTrackedVehicles from '../lib/useTrackedVehicles' import { useDepthRequest } from '@mbari/utils/useDepthRequest' +import { useRefreshPositions } from '../lib/useRefreshPositions' // This is a tricky workaround to prevent leaflet from crashing next.js // SSR. If we don't do this, the leaflet map will be loaded server side @@ -100,6 +101,23 @@ const DeploymentMap: React.FC = ({ const { trackedVehicles } = useTrackedVehicles() const [showAll, setShowAll] = useState(true) + + const vehiclesToRefresh = vehicleName + ? [vehicleName] + : trackedVehicles.map((v) => (typeof v === 'string' ? v : v)) + const { + refreshAll, + lastRefreshed, + loading: refreshLoading, + markInitialLoadDone, + } = useRefreshPositions(vehiclesToRefresh, { + autoRefreshMinutes: 10, + // Use vehicle's time range so toast counts match the map + preferredParams: + vehicleName && startTime != null + ? { [vehicleName]: { from: startTime, to: endTime ?? undefined } } + : undefined, + }) const [isTimelineScrubbing, setIsTimelineScrubbing] = useState(false) const [center, setCenter] = useState() const [centerZoom, setCenterZoom] = useState(undefined) @@ -571,6 +589,11 @@ const DeploymentMap: React.FC = ({ onRequestFitBounds={handleFitBoundsRequest} onRequestStations={handleLayersRequest} onRequestVehicleColors={handleVehicleColorRequest} + onRequestRefresh={refreshAll} + refreshLoading={refreshLoading} + refreshLastRefreshed={lastRefreshed} + refreshAutoRefreshMinutes={10} + refreshTooltipPreamble="Reload LRAUV positions" isAddingMarkers={isAddingMarkers} onToggleMarkerMode={handleToggleMarkerMode} onRequestMarkers={handleMarkersRequest} @@ -671,6 +694,7 @@ const DeploymentMap: React.FC = ({ indicatorTime={indicatorTime} onScrub={handleMapScrub} onGPSFix={handleGPSFix} + onPositionDataLoaded={markInitialLoadDone} // Disable map auto-fit centering when scrubbing the timeline disableAutoFit={isTimelineScrubbing} /> diff --git a/apps/lrauv-dash2/components/VehicleList.tsx b/apps/lrauv-dash2/components/VehicleList.tsx index b22f572c..59da9609 100644 --- a/apps/lrauv-dash2/components/VehicleList.tsx +++ b/apps/lrauv-dash2/components/VehicleList.tsx @@ -20,7 +20,7 @@ import { calculateRelativeNextComm, decodeHtmlEntities, } from '@mbari/utils' -import React, { useEffect } from 'react' +import React, { useEffect, useMemo } from 'react' import useTrackedVehicles from '../lib/useTrackedVehicles' import axios from 'axios' import { DateTime } from 'luxon' @@ -61,10 +61,12 @@ const ConnectedVehicleCellComponent: React.FC<{ }, { staleTime: 5 * 60 * 1000 } ) + + const defaultFrom = useMemo(() => Date.now() - 24 * 60 * 60 * 1000, []) const { data: vehiclePosition, isLoading: positionLoading } = useVehiclePos( { vehicle: name, - from: lastDeployment?.lastEvent ?? 0, + from: lastDeployment?.lastEvent ?? defaultFrom, }, { enabled: !!lastDeployment?.lastEvent, diff --git a/apps/lrauv-dash2/components/VehiclePath.tsx b/apps/lrauv-dash2/components/VehiclePath.tsx index 9d46b7dd..5900beda 100644 --- a/apps/lrauv-dash2/components/VehiclePath.tsx +++ b/apps/lrauv-dash2/components/VehiclePath.tsx @@ -10,6 +10,7 @@ import { LatLng, LeafletMouseEventHandlerFn } from 'leaflet' import { useSharedPath } from './SharedPathContextProvider' import { distance } from '@turf/turf' import { parseISO, getTime } from 'date-fns' +import { formatElapsedTime } from '@mbari/utils' import { useVehicleColors } from './VehicleColorsContext' const getDistance = (a: VPosDetail, b: LatLng) => @@ -54,6 +55,7 @@ interface VehiclePathProps { indicatorTime?: number | null onScrub?: (millis?: number | null) => void onGPSFix?: (gps: VPosDetail) => void + onPositionDataLoaded?: () => void disableAutoFit?: boolean } @@ -66,6 +68,7 @@ const VehiclePath: React.FC = ({ indicatorTime, onScrub: handleScrub, onGPSFix: handleGPSFix, + onPositionDataLoaded, disableAutoFit = false, }) => { const map = useMap() @@ -77,10 +80,13 @@ const VehiclePath: React.FC = ({ }, { staleTime: 5 * 60 * 1000, enabled: !from } ) + // Default to 24 hours ago if no deployment data is available + // Memoize to prevent query key from changing on every render + const defaultFrom = useMemo(() => Date.now() - 24 * 60 * 60 * 1000, []) const { data: vehiclePosition } = useVehiclePos( { vehicle: name as string, - from: from ? from : lastDeployment?.startEvent?.unixTime ?? 0, + from: from ? from : lastDeployment?.startEvent?.unixTime ?? defaultFrom, to: from ? to : lastDeployment?.endEvent?.unixTime, }, { @@ -119,6 +125,7 @@ const VehiclePath: React.FC = ({ // Handle GPS Fixes const latestGPS = useRef<[number, number] | undefined>() + const hasNotifiedDataLoaded = useRef(false) useEffect(() => { if (vehiclePosition?.gpsFixes && vehiclePosition.gpsFixes.length > 0) { @@ -136,6 +143,18 @@ const VehiclePath: React.FC = ({ } }, [vehiclePosition, handleScrub, handleGPSFix]) + // Notify parent once when position data is available (for refresh "first load" countdown) + useEffect(() => { + if ( + !onPositionDataLoaded || + hasNotifiedDataLoaded.current || + !vehiclePosition?.gpsFixes?.length + ) + return + hasNotifiedDataLoaded.current = true + onPositionDataLoaded() + }, [vehiclePosition?.gpsFixes, onPositionDataLoaded]) + const timeout = useRef | null>(null) // handleCoordinates @@ -309,6 +328,11 @@ const VehiclePath: React.FC = ({ } } + // Derived for tooltip (compact format, updates on re-render) + const timeSinceFixDisplay = latestTimeFix + ? formatElapsedTime(Date.now() - getTime(parseISO(latestTimeFix))) + : '' + return route?.length ? ( <> = ({ ' ' + indicatorCoord.isoTime.split('T')[1].split('Z')[0]} {' - '} - {timeSinceFix} + {timeSinceFixDisplay} )} diff --git a/apps/lrauv-dash2/lib/useRefreshPositions.tsx b/apps/lrauv-dash2/lib/useRefreshPositions.tsx new file mode 100644 index 00000000..a5800682 --- /dev/null +++ b/apps/lrauv-dash2/lib/useRefreshPositions.tsx @@ -0,0 +1,227 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { useQueryClient } from 'react-query' +import { GetVPosResponse, GetVPosParams } from '@mbari/api-client' +import toast from 'react-hot-toast' +import { createLogger } from '@mbari/utils' + +const logger = createLogger('useRefreshPositions') + +export interface VehiclePositionInfo { + vehicleName: string + gpsFixes: number + argoReceives: number + emergencies: number + reachedWaypoints: number +} + +export interface PreferredQueryParams { + from: number + to?: number +} + +export interface UseRefreshPositionsOptions { + autoRefreshMinutes?: number + preferredParams?: Record +} + +/** Duration the refresh-position toast is shown and throttle window before another refresh is allowed. */ +const REFRESH_TOAST_DURATION_MS = 3000 + +const getRefreshToastId = (vehicleName: string) => + `refresh-positions-${vehicleName}` + +export interface UseRefreshPositionsReturn { + loading: boolean + lastRefreshed: Date | null + refreshAll: () => Promise | undefined> + /** Call once when vehicle position data has first loaded (e.g. from VehiclePath) so "next refresh" countdown can start. */ + markInitialLoadDone: () => void +} + +export const useRefreshPositions = ( + vehicleNames: string[], + options: UseRefreshPositionsOptions = {} +): UseRefreshPositionsReturn => { + const queryClient = useQueryClient() + const [loading, setLoading] = useState(false) + const [lastRefreshed, setLastRefreshed] = useState(null) + const initializedRef = useRef(false) + const lastRefreshRunRef = useRef(0) + + const { autoRefreshMinutes, preferredParams } = options + + /** Call when vehicle position data has first loaded so "next refresh" countdown can start. Only set once. */ + const markInitialLoadDone = useCallback(() => { + if (initializedRef.current) return + setLastRefreshed(new Date()) + initializedRef.current = true + }, []) + + // Fallback: if no VehiclePath reports data within 30s (e.g. no vehicles or slow load), set lastRefreshed so tooltip still shows + useEffect(() => { + if (vehicleNames.length === 0) return + const timeout = setTimeout(() => { + markInitialLoadDone() + }, 30000) + return () => clearTimeout(timeout) + }, [vehicleNames.length, markInitialLoadDone]) + + const showPositionNotification = useCallback( + (vehicleNamesList: string[], results: Record) => { + // One toast per vehicle; always show GPS fixes (including 0), others only if > 0 + vehicleNamesList.forEach((vehicleName) => { + const data = results[vehicleName] + const gpsFixes = data?.gpsFixes?.length ?? 0 + const argoReceives = data?.argoReceives?.length ?? 0 + const emergencies = data?.emergencies?.length ?? 0 + const reachedWaypoints = data?.reachedWaypoints?.length ?? 0 + + const hasEmergencies = emergencies > 0 + const timeout = hasEmergencies ? 0 : REFRESH_TOAST_DURATION_MS + + const message = ( +
+ Loaded positions: {vehicleName} +
+ {emergencies > 0 && ( +
{emergencies} Emergency
+ )} +
{gpsFixes} GPS fixes
+ {argoReceives > 0 &&
{argoReceives} Argo
} + {reachedWaypoints > 0 &&
{reachedWaypoints} RWP
} +
+
+ ) + + toast(message, { + id: getRefreshToastId(vehicleName), + position: 'bottom-left', + duration: timeout || undefined, + className: 'refresh-position-toast', + style: { + backgroundColor: '#424242', + color: '#fff', + }, + }) + }) + }, + [] + ) + + const refreshAll = useCallback(async () => { + if (vehicleNames.length === 0) { + logger.warn('No vehicles to refresh') + return + } + + const now = Date.now() + if (now - lastRefreshRunRef.current < REFRESH_TOAST_DURATION_MS) { + return + } + lastRefreshRunRef.current = now + + setLoading(true) + try { + // Refetch the actual queries that VehiclePath components are using + // This ensures we're refreshing with the same params that are displayed on the map + await Promise.all( + vehicleNames.map((vehicleName) => + queryClient.refetchQueries({ + queryKey: ['info', 'vehiclePos'], + predicate: (query) => { + const queryParams = query.queryKey[2] as GetVPosParams | undefined + // Only refetch queries with valid parameters: + // - vehicle must match + // - from must be present and > 0 + return ( + queryParams?.vehicle === vehicleName && + queryParams?.from !== undefined && + queryParams.from > 0 + ) + }, + }) + ) + ) + + // Get the updated data from React Query cache to show notifications. + // Prefer the query that matches preferredParams (e.g. deployment page time range) so toast counts match the current view. + const results: Record = {} + const queries = queryClient.getQueriesData({ + queryKey: ['info', 'vehiclePos'], + exact: false, + }) + + vehicleNames.forEach((vehicleName) => { + const preferred = preferredParams?.[vehicleName] + let match: GetVPosResponse | undefined + let fallback: GetVPosResponse | undefined + + for (const [queryKey, data] of queries) { + const queryParams = queryKey[2] as GetVPosParams | undefined + if (queryParams?.vehicle !== vehicleName || !data) continue + + const response = data as GetVPosResponse + + if (preferred) { + const fromMatch = queryParams.from === preferred.from + const toMatch = + preferred.to == null ? true : queryParams.to === preferred.to + if (fromMatch && toMatch) { + match = response + break + } + } else { + // No preferredParams (e.g. overview): prefer query with to (VehiclePath last-deployment window) + if (queryParams.to !== undefined) { + match = response + break + } + if (!fallback) fallback = response + } + } + const chosen = match ?? fallback + if (chosen) results[vehicleName] = chosen + }) + + setLastRefreshed(new Date()) + initializedRef.current = true // Mark as initialized after first manual refresh + showPositionNotification(vehicleNames, results) + + return results + } catch (error) { + logger.error('Error refreshing positions:', error) + toast.error('Failed to refresh vehicle positions', { + position: 'bottom-left', + }) + throw error + } finally { + setLoading(false) + } + }, [vehicleNames, preferredParams, showPositionNotification, queryClient]) + + // Auto-refresh functionality + useEffect(() => { + if (!autoRefreshMinutes || !lastRefreshed) return + + const nextRefreshTime = + lastRefreshed.getTime() + autoRefreshMinutes * 60 * 1000 + const timeUntil = nextRefreshTime - Date.now() + + if (timeUntil <= 0) return + + const timeout = setTimeout(() => { + refreshAll().catch((error) => { + logger.error('Auto-refresh failed:', error) + }) + }, timeUntil) + + return () => clearTimeout(timeout) + }, [lastRefreshed, autoRefreshMinutes, refreshAll]) + + return { + loading, + lastRefreshed, + refreshAll, + markInitialLoadDone, + } +} diff --git a/apps/lrauv-dash2/pages/index.tsx b/apps/lrauv-dash2/pages/index.tsx index b79cd7c0..2011bb54 100644 --- a/apps/lrauv-dash2/pages/index.tsx +++ b/apps/lrauv-dash2/pages/index.tsx @@ -1,7 +1,7 @@ import { OverviewToolbar } from '@mbari/react-ui' import { NextPage } from 'next' import Layout from '../components/Layout' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import dynamic from 'next/dynamic' import VehicleDeploymentDropdown from '../components/VehicleDeploymentDropdown' import VehicleList from '../components/VehicleList' @@ -25,6 +25,7 @@ import toast from 'react-hot-toast' import type { MapProps } from '@mbari/react-ui/dist/Map/Map' import { createLogger } from '@mbari/utils' import { PlatformsListModal } from '../components/PlatformsListModal' +import { useRefreshPositions } from '../lib/useRefreshPositions' // This is a tricky workaround to prevent leaflet from crashing next.js // SSR. If we don't do this, the leaflet map will be loaded server side @@ -79,6 +80,7 @@ type CustomMapProps = MapProps & isAddingMarkers?: boolean onToggleMarkerMode?: () => void trackedVehicles?: { name: string; id?: string }[] // Match the actual type being used + onRequestRefresh?: () => void } // Interface MarkerData @@ -147,6 +149,16 @@ const OverViewMap: React.FC<{ // Store all vehicle positions for bounds calculation const vehiclePositions = useRef>([]) + const vehicleNames = trackedVehicles.map((v) => v.name) + const { + refreshAll, + loading: refreshLoading, + lastRefreshed, + markInitialLoadDone, + } = useRefreshPositions(vehicleNames, { + autoRefreshMinutes: 10, + }) + // Effect to handle vehicle positions useEffect(() => { // Reset positions when component unmounts or tracked vehicles change @@ -640,6 +652,11 @@ const OverViewMap: React.FC<{ onRequestStations={handleLayersRequest} onRequestVehicleColors={handleVehicleColorRequest} onRequestMarkers={handleMarkersRequest} + onRequestRefresh={refreshAll} + refreshLoading={refreshLoading} + refreshLastRefreshed={lastRefreshed} + refreshAutoRefreshMinutes={10} + refreshTooltipPreamble="Reload LRAUV positions" isAddingMarkers={isAddingMarkers} onToggleMarkerMode={handleToggleMarkerMode} renderMapClickHandler={() => ( @@ -697,6 +714,7 @@ const OverViewMap: React.FC<{ name={name.name} key={`path-${name}-${index}`} onGPSFix={handleGPSFix} + onPositionDataLoaded={markInitialLoadDone} grouped /> ))} diff --git a/packages/react-ui/src/Map/Map.tsx b/packages/react-ui/src/Map/Map.tsx index 4bd36d2c..aacdaf96 100644 --- a/packages/react-ui/src/Map/Map.tsx +++ b/packages/react-ui/src/Map/Map.tsx @@ -35,6 +35,7 @@ import { AreaComponent, PathComponent, MeasurementProps } from './Measurement' import { CenterView } from './MapViews' import { createLogger, loadGoogleMapsOnce } from '@mbari/utils' import VehicleColorsModal from '@mbari/lrauv-dash2/components/VehicleColorsModal' +import { RefreshButton } from './RefreshButton' const logger = createLogger('Map') @@ -104,6 +105,11 @@ export interface MapProps extends React.HTMLAttributes { onRequestPlatforms?: () => void onRequestStations?: (position?: { top: number; left: number }) => void onRequestVehicleColors?: (vehicleName?: string) => void + onRequestRefresh?: () => void + refreshLastRefreshed?: Date | null + refreshAutoRefreshMinutes?: number + refreshTooltipPreamble?: string + refreshLoading?: boolean whenCreated?: (map: L.Map) => void onMapReady?: (map: L.Map) => void trackedVehicles?: Array<{ id: string; name: string }> @@ -160,6 +166,11 @@ const Map = React.forwardRef( onRequestPlatforms, onRequestStations, onRequestVehicleColors, + onRequestRefresh, + refreshLastRefreshed, + refreshAutoRefreshMinutes, + refreshTooltipPreamble, + refreshLoading, onMapReady, renderMapClickHandler, renderCustomMarkerSet, @@ -861,6 +872,24 @@ const Map = React.forwardRef( + {onRequestRefresh && ( +
+ +
+ )} {children} {/* TRACKDB/STATIONS CONTROLS - Now in separate Control component */} diff --git a/packages/react-ui/src/Map/RefreshButton.tsx b/packages/react-ui/src/Map/RefreshButton.tsx new file mode 100644 index 00000000..925aad87 --- /dev/null +++ b/packages/react-ui/src/Map/RefreshButton.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState, useMemo } from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faSync } from '@fortawesome/free-solid-svg-icons' +import Tippy from '@tippyjs/react' +import { formatElapsedTime } from '@mbari/utils' + +export interface RefreshButtonProps { + onClick: () => void + loading?: boolean + disabled?: boolean + className?: string + lastRefreshed?: Date | null + autoRefreshMinutes?: number + tooltipPreamble?: string +} + +export const RefreshButton: React.FC = ({ + onClick, + loading = false, + disabled = false, + className = '', + lastRefreshed, + autoRefreshMinutes, + tooltipPreamble = 'Refresh vehicle positions', +}) => { + // Update elapsed time every 2 seconds; initial "0s" for first paint + const [elapsedTime, setElapsedTime] = useState('0s') + const [nextAutoRefreshCountdown, setNextAutoRefreshCountdown] = + useState('') + + useEffect(() => { + if (loading) { + setElapsedTime('') + setNextAutoRefreshCountdown('') + return + } + + const update = () => { + // Always update elapsed time (show "0s" if never refreshed) + if (lastRefreshed) { + const elapsed = formatElapsedTime(Date.now() - lastRefreshed.getTime()) + setElapsedTime(elapsed) + } else { + setElapsedTime('0s') + } + + // Always update next auto-refresh countdown if auto-refresh is enabled + if (autoRefreshMinutes) { + if (lastRefreshed) { + const nextTime = new Date( + lastRefreshed.getTime() + autoRefreshMinutes * 60 * 1000 + ) + const timeUntil = nextTime.getTime() - Date.now() + // Always show countdown (even if < 10 seconds or negative) + if (timeUntil > 0) { + setNextAutoRefreshCountdown(formatElapsedTime(timeUntil)) + } else { + setNextAutoRefreshCountdown('0s') + } + } else { + // If never refreshed, show countdown from now + setNextAutoRefreshCountdown( + formatElapsedTime(autoRefreshMinutes * 60 * 1000) + ) + } + } + } + + update() // Initial update + const interval = setInterval(update, 2000) + + return () => clearInterval(interval) + }, [lastRefreshed, loading, autoRefreshMinutes]) + + // Build tooltip content (effect keeps elapsedTime / nextAutoRefreshCountdown in sync) + const tooltipContent = ( +
+
{tooltipPreamble}
+ {!loading && ( +
+ {elapsedTime} + since last reload. + {autoRefreshMinutes && ( +
+
Next auto-refresh in
+ {nextAutoRefreshCountdown} +
+ )} +
+ )} +
+ ) + + return ( + + + + ) +} diff --git a/packages/react-ui/src/css/base.css b/packages/react-ui/src/css/base.css index 018734fe..3f735c65 100644 --- a/packages/react-ui/src/css/base.css +++ b/packages/react-ui/src/css/base.css @@ -245,7 +245,8 @@ ul.tree input[type='checkbox'] { #allVehicles-center, #mapLayersdb, #vehicleColors, -#toggle-markers { +#toggle-markers, +#refreshBtn { color: #fff !important; background-color: #8c9eff !important; box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 5px 8px rgba(0, 0, 0, 0.14), @@ -260,6 +261,12 @@ ul.tree input[type='checkbox'] { width: 42px; height: 42px; border-radius: 0.25rem; + transition: filter 1s ease; +} + +/* Active state for refresh button - visual feedback when pressed */ +#refreshBtn:active { + filter: brightness(0.15) !important; } @keyframes pulse { 0% { @@ -459,6 +466,18 @@ div[role='status'][aria-live='polite'] { @apply blue-toast !important; } +/* Disable animation delays for instant toast appearance - refresh position notifications only */ +.refresh-position-toast, +.refresh-position-toast[data-animation='enter'], +.refresh-position-toast[data-animation='exit'], +.refresh-position-toast[data-animation='slide'], +.refresh-position-toast[data-animation='fade'] { + animation: none !important; + animation-duration: 0ms !important; + transition: none !important; + animation-delay: 0ms !important; +} + /* If blue-toast doesn't exist yet, define it */ .blue-toast { background-color: rgb(59, 130, 246) !important; /* Tailwind blue-500 */ diff --git a/packages/utils/src/formatElapsedTime.ts b/packages/utils/src/formatElapsedTime.ts new file mode 100644 index 00000000..328ebbf5 --- /dev/null +++ b/packages/utils/src/formatElapsedTime.ts @@ -0,0 +1,64 @@ +/** + * Formats elapsed time in milliseconds to a compact string format. + * + * @param elapsedMs - Elapsed time in milliseconds (can be negative for future times) + * @returns Formatted string (e.g., "45s", "23m", "5h:30m", "48h", "5d:12h", "6M", "2y") + * + * Format rules: + * - 0-99 seconds: "Xs" (e.g., "45s") + * - 0-99 minutes: "Xm" (e.g., "23m") + * - 0-24 hours: "Xh:Ym" (e.g., "5h:30m") + * - 1-3 days: "Xh" (e.g., "48h") + * - 1-99 days: "Xd:Yh" (e.g., "5d:12h") + * - 1-24 months: "XM" (e.g., "6M") + * - 1+ years: "Xy" (e.g., "2y") + */ +export function formatElapsedTime(elapsedMs: number): string { + const ms = Math.abs(elapsedMs) + const seconds = ms / 1000.0 + + // 0-99 seconds: "Xs" + if (seconds <= 99) { + return `${Math.round(seconds)}s` + } + + const minutes = seconds / 60.0 + + // 0-99 minutes: "Xm" + if (minutes <= 99) { + return `${Math.round(minutes)}m` + } + + const hoursF = minutes / 60.0 + + // 0-24 hours: "Xh:Ym" or "Xh" + if (hoursF <= 24) { + const hours = Math.floor(hoursF) + const remMins = Math.floor((hoursF - hours) * 60.0) + return remMins > 0 ? `${hours}h:${remMins}m` : `${hours}h` + } + + // 1-3 days (24-72 hours): "Xh" + if (hoursF <= 72) { + return `${Math.round(hoursF)}h` + } + + const daysF = hoursF / 24.0 + const days = Math.floor(daysF) + + // 1-99 days: "Xd:Yh" or "Xd" + if (days <= 99) { + const remHours = Math.floor((daysF - days) * 24.0) + return remHours > 0 ? `${days}d:${remHours}h` : `${days}d` + } + + // 1-24 months: "XM" + const months = daysF / 30.0 + if (months <= 24) { + return `${Math.round(months)}M` + } + + // 1+ years: "Xy" + const years = Math.round(daysF / 365.0) + return `${years}y` +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 04c20caa..247486f0 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -42,6 +42,7 @@ export * from './getUnitFromAbbreviation' export * from './parseMissionPath' export * from './convertMissionDataToListItem' export * from './formatCompactDuration' +export * from './formatElapsedTime' export * from './nextCommsTimeFormatting' export * from './calculateNextComm' export * from './decodeHtmlEntities'