From 397f2d5fed7e9576f6c0fd484f2d3ef3b6c6851d Mon Sep 17 00:00:00 2001 From: Aaron Plave Date: Fri, 19 Jul 2024 16:05:07 -0700 Subject: [PATCH] Time systems plugin (#1342) * add UI plugin architecture with initial support scoped to time systems * use time systems plugin to display and parse times throughout the plans and plan pages * load time plugin if env var set * hold shift to display additional time formats in timeline tooltip when hovering over activities and resources --------- Co-authored-by: Chet Joswig --- .env | 1 + .gitignore | 1 + docs/ENVIRONMENT.md | 21 +-- e2e-tests/tests/plan-merge.test.ts | 2 +- .../ActivityDirectiveChangelog.svelte | 23 ++- .../activity/ActivityDirectiveForm.svelte | 42 +++-- .../ActivityDirectivesTablePanel.svelte | 38 ++++- .../activity/ActivityFormPanel.svelte | 1 - .../activity/ActivitySpanForm.svelte | 28 ++-- .../activity/ActivitySpansTablePanel.svelte | 97 ++++++++---- .../ConstraintViolationButton.svelte | 57 ++++--- .../constraints/ConstraintsPanel.svelte | 79 ++++++---- src/components/form/DatePickerField.svelte | 68 +++++--- src/components/form/Input.svelte | 5 + .../model/ModelAssociationsListItem.svelte | 71 ++++----- src/components/plan/PlanForm.svelte | 26 +++- .../scheduling/goals/SchedulingGoal.svelte | 9 +- .../simulation/SimulationEventsPanel.svelte | 32 +++- .../SimulationHistoryDataset.svelte | 28 +++- .../simulation/SimulationPanel.svelte | 109 ++++++++----- src/components/timeline/Timeline.svelte | 63 +++----- .../timeline/TimelineCursors.svelte | 19 ++- .../timeline/TimelineHistogram.svelte | 13 +- .../timeline/TimelineTimeDisplay.svelte | 15 +- src/components/timeline/Tooltip.svelte | 147 ++++++++++++++++-- src/components/timeline/XAxis.svelte | 44 ++++-- .../timeline/form/TimelineEditorPanel.svelte | 3 +- src/components/ui/IconCellRenderer.svelte | 35 +++++ src/constants/time.ts | 1 + src/css/tooltip.css | 1 + src/routes/+layout.svelte | 92 ++++++++++- src/routes/plans/+page.svelte | 128 ++++++++++----- src/routes/tags/+page.svelte | 2 +- src/stores/plugins.ts | 9 ++ src/types/plugin.ts | 30 ++++ src/types/timeline.ts | 5 +- src/utilities/plugins.ts | 40 +++++ src/utilities/time.test.ts | 8 +- src/utilities/time.ts | 27 +++- src/utilities/timeline.ts | 22 ++- src/utilities/validators.test.ts | 18 +-- src/utilities/validators.ts | 9 +- src/utilities/view.ts | 23 --- 43 files changed, 1065 insertions(+), 427 deletions(-) create mode 100644 src/components/ui/IconCellRenderer.svelte create mode 100644 src/constants/time.ts create mode 100644 src/stores/plugins.ts create mode 100644 src/types/plugin.ts create mode 100644 src/utilities/plugins.ts diff --git a/.env b/.env index eb70d13e36..32b99a5e66 100644 --- a/.env +++ b/.env @@ -6,5 +6,6 @@ PUBLIC_HASURA_CLIENT_URL=http://localhost:8080/v1/graphql PUBLIC_HASURA_SERVER_URL=http://localhost:8080/v1/graphql PUBLIC_HASURA_WEB_SOCKET_URL=ws://localhost:8080/v1/graphql PUBLIC_AUTH_SSO_ENABLED=false +PUBLIC_TIME_PLUGIN_ENABLED=false # VITE_HOST=localhost.jpl.nasa.gov # VITE_HTTPS=true diff --git a/.gitignore b/.gitignore index fab4586cb8..4ab86311c3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ e2e-test-results node_modules /package /static/version.json +/static/resources/* /.svelte-kit test-results unit-test-results diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index e5a6aebfb0..2329043c58 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -2,13 +2,14 @@ This document provides detailed information about environment variables for Aerie UI. -| Name | Description | Type | Default | -| -------------------------------- | --------------------------------------------------------------------------------------------------------- | -------- | -------------------------------- | -| `ORIGIN` | Url of where the UI is served from. See the [Svelte Kit Adapter Node docs][svelte-kit-adapter-node-docs]. | `string` | http://localhost | -| `PUBLIC_AERIE_FILE_STORE_PREFIX` | Prefix to prepend to files uploaded through simulation configuration. | `string` | /usr/src/app/merlin_file_store/ | -| `PUBLIC_AUTH_SSO_ENABLED` | Whether to use the SSO-based auth flow, or the /login page auth flow | `string` | false | -| `PUBLIC_GATEWAY_CLIENT_URL` | Url of the Gateway as called from the client (i.e. web browser) | `string` | http://localhost:9000 | -| `PUBLIC_GATEWAY_SERVER_URL` | Url of the Gateway as called from the server (i.e. Node.js container) | `string` | http://localhost:9000 | -| `PUBLIC_HASURA_CLIENT_URL` | Url of Hasura as called from the client (i.e. web browser) | `string` | http://localhost:8080/v1/graphql | -| `PUBLIC_HASURA_SERVER_URL` | Url of Hasura as called from the server (i.e. Node.js container) | `string` | http://localhost:8080/v1/graphql | -| `PUBLIC_HASURA_WEB_SOCKET_URL` | Url of Hasura called to establish a web-socket connection from the client | `string` | ws://localhost:8080/v1/graphql | +| Name | Description | Type | Default | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------- | +| `ORIGIN` | Url of where the UI is served from. See the [Svelte Kit Adapter Node docs][svelte-kit-adapter-node-docs]. | `string` | http://localhost | +| `PUBLIC_AERIE_FILE_STORE_PREFIX` | Prefix to prepend to files uploaded through simulation configuration. | `string` | /usr/src/app/merlin_file_store/ | +| `PUBLIC_AUTH_SSO_ENABLED` | Whether to use the SSO-based auth flow, or the /login page auth flow | `string` | false | +| `PUBLIC_GATEWAY_CLIENT_URL` | Url of the Gateway as called from the client (i.e. web browser) | `string` | http://localhost:9000 | +| `PUBLIC_GATEWAY_SERVER_URL` | Url of the Gateway as called from the server (i.e. Node.js container) | `string` | http://localhost:9000 | +| `PUBLIC_HASURA_CLIENT_URL` | Url of Hasura as called from the client (i.e. web browser) | `string` | http://localhost:8080/v1/graphql | +| `PUBLIC_HASURA_SERVER_URL` | Url of Hasura as called from the server (i.e. Node.js container) | `string` | http://localhost:8080/v1/graphql | +| `PUBLIC_HASURA_WEB_SOCKET_URL` | Url of Hasura called to establish a web-socket connection from the client | `string` | ws://localhost:8080/v1/graphql | +| `PUBLIC_TIME_PLUGIN_ENABLED` | Whether the client should load a user-supplied `time-plugin.js` plugin from the `static/resources` directory. | `string` | false | diff --git a/e2e-tests/tests/plan-merge.test.ts b/e2e-tests/tests/plan-merge.test.ts index 7656c06953..22a900baa0 100644 --- a/e2e-tests/tests/plan-merge.test.ts +++ b/e2e-tests/tests/plan-merge.test.ts @@ -44,7 +44,7 @@ test.afterAll(async () => { }); test.describe.serial('Plan Merge', () => { - const newActivityStartTime: string = '2022-005T00:00:00.000'; + const newActivityStartTime: string = '2022-005T00:00:00'; const planBranchName = uniqueNamesGenerator({ dictionaries: [adjectives, colors, animals] }); test('Add an activity to the parent plan', async () => { diff --git a/src/components/activity/ActivityDirectiveChangelog.svelte b/src/components/activity/ActivityDirectiveChangelog.svelte index b167451216..c4011255af 100644 --- a/src/components/activity/ActivityDirectiveChangelog.svelte +++ b/src/components/activity/ActivityDirectiveChangelog.svelte @@ -4,6 +4,7 @@ import HistoryIcon from '@nasa-jpl/stellar/icons/history.svg?component'; import { createEventDispatcher, onMount } from 'svelte'; import { plan } from '../../stores/plan'; + import { plugins } from '../../stores/plugins'; import type { ActivityDirective, ActivityDirectiveRevision, @@ -15,7 +16,7 @@ import effects from '../../utilities/effects'; import { permissionHandler } from '../../utilities/permissionHandler'; import { featurePermissions } from '../../utilities/permissions'; - import { convertUsToDurationString, getDoyTimeFromInterval } from '../../utilities/time'; + import { convertUsToDurationString, formatDate, getUnixEpochTimeFromInterval } from '../../utilities/time'; import { tooltip } from '../../utilities/tooltip'; const dispatch = createEventDispatcher<{ @@ -48,7 +49,7 @@ let effectiveRevisionArguments: (ArgumentsMap | undefined)[]; $: effectiveRevisionArguments = []; - function formatDate(dateString: string) { + function formatRevisionDate(dateString: string) { return new Date(dateString).toLocaleString(undefined, { day: 'numeric', hour: 'numeric', @@ -112,12 +113,18 @@ // Manually check remaining fields that could have changed and require extra formatting if (current.start_offset !== previous.start_offset) { - const currentStartTimeDoy = getDoyTimeFromInterval(planStartTimeYmd, current.start_offset); - const previousStartTimeDoy = getDoyTimeFromInterval(planStartTimeYmd, previous.start_offset); + const currentStartTime = formatDate( + new Date(getUnixEpochTimeFromInterval(planStartTimeYmd, current.start_offset)), + $plugins.time.primary.format, + ); + const previousStartTime = formatDate( + new Date(getUnixEpochTimeFromInterval(planStartTimeYmd, previous.start_offset)), + $plugins.time.primary.format, + ); - differences['Start Time (UTC)'] = { - currentValue: currentStartTimeDoy, - previousValue: previousStartTimeDoy, + differences[`Start Time (${$plugins.time.primary.label})`] = { + currentValue: currentStartTime, + previousValue: previousStartTime, }; } @@ -240,7 +247,7 @@ {#if activityRevisionChangeMap.length} {#each activityRevisions as revision, i}
-
{formatDate(revision.changed_at)}
+
{formatRevisionDate(revision.changed_at)}
= {}; let parametersWithErrorsCount: number = 0; - let startTimeDoy: string = getDoyTimeFromInterval( - planStartTimeYmd, - revision ? revision.start_offset : activityDirective.start_offset, - ); - let startTimeDoyField: FieldStore = field(startTimeDoy, [required, timestamp]); + let startTime: string; + let startTimeField: FieldStore; $: if (user !== null && $plan !== null) { hasUpdatePermission = @@ -99,11 +97,17 @@ $: highlightKeysMap = keyByBoolean(highlightKeys); $: activityType = (activityTypes ?? []).find(({ name: activityTypeName }) => activityDirective?.type === activityTypeName) ?? null; - $: startTimeDoy = getDoyTimeFromInterval( - planStartTimeYmd, - revision ? revision.start_offset : activityDirective.start_offset, - ); - $: startTimeDoyField.validateAndSet(startTimeDoy); + $: { + const startTimeMs = getUnixEpochTimeFromInterval( + planStartTimeYmd, + revision ? revision.start_offset : activityDirective.start_offset, + ); + startTime = formatDate(new Date(startTimeMs), $plugins.time.primary.format); + } + + $: startTimeField = field(startTime, [required, $plugins.time.primary.validate]); + $: activityNameField = field(activityDirective.name); + $: startTimeField.validateAndSet(startTime); $: activityNameField.validateAndSet(activityDirective.name); $: if (activityType && activityDirective.arguments) { @@ -369,10 +373,15 @@ } function onUpdateStartTime() { - if ($startTimeDoyField.valid && startTimeDoy !== $startTimeDoyField.value) { + if ($startTimeField.valid && startTime !== $startTimeField.value) { const { id } = activityDirective; const planStartTimeDoy = getDoyTime(new Date(planStartTimeYmd)); - const start_offset = getIntervalFromDoyRange(planStartTimeDoy, $startTimeDoyField.value); + const startTimeDate = $plugins.time.primary.parse($startTimeField.value); + if (!startTimeDate) { + return; + } + const startTimeDoy = getDoyTime(startTimeDate); + const start_offset = getIntervalFromDoyRange(planStartTimeDoy, startTimeDoy); if ($plan) { effects.updateActivityDirective($plan, id, { start_offset }, activityType, user); } @@ -556,9 +565,10 @@ import TableFillIcon from '@nasa-jpl/stellar/icons/table_fill.svg?component'; import TableFitIcon from '@nasa-jpl/stellar/icons/table_fit.svg?component'; - import type { ColDef, ColumnResizedEvent, ColumnState, ValueGetterParams } from 'ag-grid-community'; + import type { + ColDef, + ColumnResizedEvent, + ColumnState, + ICellRendererParams, + ValueGetterParams, + } from 'ag-grid-community'; import { debounce } from 'lodash-es'; + import { InvalidDate } from '../../constants/time'; import { activityDirectivesMap, selectActivity, selectedActivityDirectiveId } from '../../stores/activities'; import { activityErrorRollupsMap } from '../../stores/errors'; import { plan, planReadOnly } from '../../stores/plan'; + import { plugins } from '../../stores/plugins'; import { view, viewTogglePanel, viewUpdateActivityDirectivesTable } from '../../stores/views'; import type { ActivityDirective } from '../../types/activity'; import type { User } from '../../types/app'; import type { AutoSizeColumns, ViewGridSection, ViewTable } from '../../types/view'; import { filterEmpty } from '../../utilities/generic'; - import { getDoyTime, getUnixEpochTimeFromInterval } from '../../utilities/time'; + import { formatDate, getUnixEpochTimeFromInterval } from '../../utilities/time'; import { tooltip } from '../../utilities/tooltip'; import GridMenu from '../menus/GridMenu.svelte'; - import type DataGrid from '../ui/DataGrid/DataGrid.svelte'; + import DataGrid from '../ui/DataGrid/DataGrid.svelte'; import { tagsCellRenderer, tagsFilterValueGetter } from '../ui/DataGrid/DataGridTags'; + import IconCellRenderer from '../ui/IconCellRenderer.svelte'; import Panel from '../ui/Panel.svelte'; import ActivityDirectivesTable from './ActivityDirectivesTable.svelte'; import ActivityTableMenu from './ActivityTableMenu.svelte'; @@ -39,6 +48,7 @@ $: activityDirectivesTable = $view?.definition.plan.activityDirectivesTable; $: autoSizeColumns = activityDirectivesTable?.autoSizeColumns; + /* eslint-disable sort-keys */ $: defaultColumnDefinitions = { anchor_id: { field: 'anchor_id', @@ -162,18 +172,32 @@ resizable: true, sortable: true, }, - start_time_ms: { + derived_start_time: { + field: 'start_time_ms', filter: 'text', - headerName: 'Absolute Start Time (UTC)', - hide: true, + headerName: `Absolute Start Time (${$plugins.time.primary.label})`, + hide: false, resizable: true, sortable: true, valueGetter: (params: ValueGetterParams) => { if ($plan && params && params.data && typeof params.data.start_time_ms === 'number') { - return getDoyTime(new Date(params.data.start_time_ms), false); + return formatDate(new Date(params.data.start_time_ms), $plugins.time.primary.format); } return ''; }, + cellRenderer: (params: ICellRendererParams) => { + if (params.value !== InvalidDate) { + return params.value; + } + const div = document.createElement('div'); + + new IconCellRenderer({ + props: { type: 'error' }, + target: div, + }); + + return div; + }, }, tags: { autoHeight: true, diff --git a/src/components/activity/ActivityFormPanel.svelte b/src/components/activity/ActivityFormPanel.svelte index 2b68fc6827..2bb4f751c4 100644 --- a/src/components/activity/ActivityFormPanel.svelte +++ b/src/components/activity/ActivityFormPanel.svelte @@ -231,7 +231,6 @@ activityTypes={$activityTypes} filteredExpansionSequences={$filteredExpansionSequences} modelId={$modelId} - planStartTimeYmd={$plan.start_time} simulationDatasetId={$simulationDatasetId} span={$selectedSpan} spansMap={$spansMap} diff --git a/src/components/activity/ActivitySpanForm.svelte b/src/components/activity/ActivitySpanForm.svelte index 34a4b665b2..23cc37d016 100644 --- a/src/components/activity/ActivitySpanForm.svelte +++ b/src/components/activity/ActivitySpanForm.svelte @@ -1,6 +1,7 @@ -
- - {#if label} - {#if layout === 'inline'} - - {:else} - +{#if !useFallback} +
+ + {#if label} + {#if layout === 'inline'} + + {:else} + + {/if} {/if} - {/if} - - - - - -
+ + + + + +
+{:else} +
+ + + + {}} /> + + +
+{/if} diff --git a/src/components/model/ModelAssociationsListItem.svelte b/src/components/model/ModelAssociationsListItem.svelte index cc199d274d..f4c4316e59 100644 --- a/src/components/model/ModelAssociationsListItem.svelte +++ b/src/components/model/ModelAssociationsListItem.svelte @@ -8,7 +8,6 @@ import { getTarget } from '../../utilities/generic'; import { permissionHandler } from '../../utilities/permissionHandler'; import { tooltip } from '../../utilities/tooltip'; - import Input from '../form/Input.svelte'; export let hasEditPermission: boolean = false; export let isSelected: boolean = false; @@ -121,42 +120,40 @@
{#if priority !== undefined}
- - - {#if hasEditPermission} -
- - -
- {/if} - + + {#if hasEditPermission} +
+ + +
+ {/if}
{/if} - - + Start Time ({$plugins.time.primary.label}) + + - - + + diff --git a/src/components/scheduling/goals/SchedulingGoal.svelte b/src/components/scheduling/goals/SchedulingGoal.svelte index 89db2eba36..a58954166e 100644 --- a/src/components/scheduling/goals/SchedulingGoal.svelte +++ b/src/components/scheduling/goals/SchedulingGoal.svelte @@ -13,7 +13,6 @@ import { tooltip } from '../../../utilities/tooltip'; import Collapse from '../../Collapse.svelte'; import ContextMenuItem from '../../context-menu/ContextMenuItem.svelte'; - import Input from '../../form/Input.svelte'; import SchedulingGoalAnalysesActivities from './SchedulingGoalAnalysesActivities.svelte'; import SchedulingGoalAnalysesBadge from './SchedulingGoalAnalysesBadge.svelte'; @@ -121,7 +120,7 @@
- +
{/if} - +
+
diff --git a/src/routes/tags/+page.svelte b/src/routes/tags/+page.svelte index eb3d767a78..4375212487 100644 --- a/src/routes/tags/+page.svelte +++ b/src/routes/tags/+page.svelte @@ -432,7 +432,7 @@ Tags - + diff --git a/src/stores/plugins.ts b/src/stores/plugins.ts new file mode 100644 index 0000000000..dd4a7e21ba --- /dev/null +++ b/src/stores/plugins.ts @@ -0,0 +1,9 @@ +import { writable, type Writable } from 'svelte/store'; +import type { Plugins } from '../types/plugin'; +import { defaultPlugins } from '../utilities/plugins'; + +/* Writeable. */ + +export const plugins: Writable = writable(defaultPlugins); +export const pluginsLoaded: Writable = writable(false); +export const pluginsError: Writable = writable(''); diff --git a/src/types/plugin.ts b/src/types/plugin.ts new file mode 100644 index 0000000000..dc00ec4ae7 --- /dev/null +++ b/src/types/plugin.ts @@ -0,0 +1,30 @@ +import type { ValidationResult } from './form'; + +export type PluginCode = { + getPlugin: () => Promise; +}; + +export type PluginTime = { + format: (date: Date) => string | null; + formatShort: (date: Date) => string | null; + formatString: string; + formatTick: (date: Date, durationMs: number, tickCount: number) => string | null; + label: string; + parse: (string: string) => Date | null; + validate: (string: string) => Promise; +}; + +type Optional = Pick, K> & Omit; + +export type Plugins = { + time: { + additional: Optional[]; + enableDatePicker: boolean; + getDefaultPlanEndDate: (start: Date) => Date | null; + primary: PluginTime; + ticks: { + getTicks: (start: Date, stop: Date, count: number) => Date[]; + maxLabelWidth: number; + }; + }; +}; diff --git a/src/types/timeline.ts b/src/types/timeline.ts index 4ea62d05de..62e993ffe0 100644 --- a/src/types/timeline.ts +++ b/src/types/timeline.ts @@ -220,10 +220,9 @@ export type VerticalGuideSelection = { }; export type XAxisTick = { + additionalLabels: string[]; date: Date; - formattedDateLocal: string; - formattedDateUTC: string; - hideLabel: boolean; + label: string; }; /** diff --git a/src/utilities/plugins.ts b/src/utilities/plugins.ts new file mode 100644 index 0000000000..cf7c287e85 --- /dev/null +++ b/src/utilities/plugins.ts @@ -0,0 +1,40 @@ +import type { PluginCode, Plugins } from '../types/plugin'; +import { getDoyTime, getTimeZoneName, getUnixEpochTime } from './time'; +import { formatTickLocalTZ, formatTickUtc, utcTicks } from './timeline'; +import { timestamp } from './validators'; + +export async function loadPluginCode(path: string) { + const code = (await import(/* @vite-ignore */ path)) as PluginCode; + return await code.getPlugin(); +} + +export const defaultPlugins: Plugins = { + time: { + additional: [ + { + format: date => date.toLocaleString(), + formatTick: formatTickLocalTZ, + label: getTimeZoneName(), + }, + ], + enableDatePicker: true, + getDefaultPlanEndDate: start => { + const end = new Date(start); + end.setDate(end.getDate() + 1); + return end; + }, + primary: { + format: (date: Date) => getDoyTime(date), + formatShort: (date: Date) => getDoyTime(date).split('T')[0], + formatString: 'YYYY-DDDThh:mm:ss', + formatTick: formatTickUtc, + label: 'UTC', + parse: (string: string) => new Date(getUnixEpochTime(string)), + validate: timestamp, + }, + ticks: { + getTicks: utcTicks, + maxLabelWidth: 130, + }, + }, +}; diff --git a/src/utilities/time.test.ts b/src/utilities/time.test.ts index efd3fbadb3..69b77a6188 100644 --- a/src/utilities/time.test.ts +++ b/src/utilities/time.test.ts @@ -19,6 +19,7 @@ import { isTimeMax, parseDoyOrYmdTime, parseDurationString, + removeDateStringMilliseconds, validateTime, } from '../../src/utilities/time'; import { TimeTypes } from '../enums/time'; @@ -239,7 +240,7 @@ test('getDoyTimeComponents', () => { test('getDoyTime', () => { const doyTime = getDoyTime(new Date(1577779200000)); - expect(doyTime).toEqual('2019-365T08:00:00.000'); + expect(doyTime).toEqual('2019-365T08:00:00'); }); test('getUnixEpochTime', () => { @@ -481,3 +482,8 @@ test('validateTime', () => { expect(validateTime('+03:59:60.000', TimeTypes.EPOCH)).toBe(true); expect(validateTime('3:59:60', TimeTypes.EPOCH)).toBe(false); }); + +test('removeDateStringMilliseconds', () => { + expect(removeDateStringMilliseconds('2024-001T00:00:00.593')).toBe('2024-001T00:00:00'); + expect(removeDateStringMilliseconds('123456.593')).toBe('123456.593'); +}); diff --git a/src/utilities/time.ts b/src/utilities/time.ts index aab3d36684..d40b073224 100644 --- a/src/utilities/time.ts +++ b/src/utilities/time.ts @@ -1,7 +1,9 @@ import { padStart } from 'lodash-es'; import parseInterval from 'postgres-interval'; +import { InvalidDate } from '../constants/time'; import { TimeTypes } from '../enums/time'; import type { ActivityDirectiveId, ActivityDirectivesMap } from '../types/activity'; +import type { PluginTime } from '../types/plugin'; import type { SpanUtilityMaps, SpansMap } from '../types/simulation'; import type { DurationTimeComponents, ParsedDoyString, ParsedDurationString, ParsedYmdString } from '../types/time'; @@ -655,14 +657,15 @@ export function getDurationTimeComponents(duration: ParsedDurationString): Durat /** * Get a day-of-year timestamp from a given JavaScript Date object. - * @example getDoyTime(new Date(1577779200000)) -> 2019-365T08:00:00.000 + * @example getDoyTime(new Date(1577779200000)) -> 2019-365T08:00:00 * @note inverse of getUnixEpochTime + * @note milliseconds will be dropped if all 0s */ export function getDoyTime(date: Date, includeMsecs = true): string { const { doy, hours, mins, msecs, secs, year } = getDoyTimeComponents(date); let doyTimestamp = `${year}-${doy}T${hours}:${mins}:${secs}`; - if (includeMsecs) { + if (includeMsecs && date.getMilliseconds() > 0) { doyTimestamp += `.${msecs}`; } @@ -888,3 +891,23 @@ export function getTimeZoneName() { } return 'UNK'; } + +/** + * Removes milliseconds from the string if in DOY time format, + * otherwise returns the original string. + */ +export function removeDateStringMilliseconds(dateString: string): string { + if (validateTime(dateString, TimeTypes.ABSOLUTE)) { + return dateString.split('.')[0]; + } + return dateString; +} + +/** + * Format a Date using a date formatter. + * Returns "Invalid Date" if null. + * @todo move this to an intermediate layer between plugin and eventual store + */ +export function formatDate(date: Date, formatter: PluginTime['format']): string { + return formatter(date) ?? InvalidDate; +} diff --git a/src/utilities/timeline.ts b/src/utilities/timeline.ts index 775161976c..690af25970 100644 --- a/src/utilities/timeline.ts +++ b/src/utilities/timeline.ts @@ -36,6 +36,7 @@ import type { XRangeLayer, } from '../types/timeline'; import { filterEmpty } from './generic'; +import { getDoyTime } from './time'; export enum TimelineLockStatus { Locked = 'Locked', @@ -119,7 +120,7 @@ export function customD3TickInterval(start: Date, stop: Date, count: number): Ti } // Based on https://github.com/d3/d3-time/blob/main/src/ticks.js -export function customD3Ticks(start: Date, stop: Date, count: number) { +export function utcTicks(start: Date, stop: Date, count: number) { const reverse = stop < start; if (reverse) { [start, stop] = [stop, start]; @@ -130,6 +131,25 @@ export function customD3Ticks(start: Date, stop: Date, count: number) { return reverse ? ticks.reverse() : ticks; } +export function formatTickUtc(date: Date, viewDurationMs: number, tickCount: number): string { + let label = getDoyTime(date); + if (viewDurationMs > durationYear * tickCount) { + label = label.slice(0, 4); + } else if (viewDurationMs > durationMonth * tickCount) { + label = label.slice(0, 8); + } else if (viewDurationMs > durationWeek) { + label = label.slice(0, 8); + } + return label; +} + +export function formatTickLocalTZ(date: Date, viewDurationMs: number, tickCount: number): string { + if (viewDurationMs > durationYear * tickCount) { + return date.getFullYear().toString(); + } + return date.toLocaleString(); +} + export const CANVAS_PADDING_X = 0; export const CANVAS_PADDING_Y = 8; diff --git a/src/utilities/validators.test.ts b/src/utilities/validators.test.ts index 5675056ae0..a3671fd89e 100644 --- a/src/utilities/validators.test.ts +++ b/src/utilities/validators.test.ts @@ -75,9 +75,8 @@ describe('validateStartTime', () => { const startTimeError = 'Simulation start must be before end'; test.each([ - { endTime: '2020-400T00:00:00.000', startTime: '2020-400T00:00:00.000' }, - { endTime: '2020-399T00:00:00.000', startTime: '2020-400T00:00:00.000' }, - { endTime: '2020-400T00:00:00.000', startTime: '2020-410T00:00:00.000' }, + { endTime: new Date('01-01-2030').getTime(), startTime: new Date('01-01-2030').getTime() }, + { endTime: new Date('01-01-2029').getTime(), startTime: new Date('01-01-2030').getTime() }, ])( 'Should return an error message for out of order start/end ($startTime/$endTime) times', async ({ endTime, startTime }) => { @@ -86,8 +85,8 @@ describe('validateStartTime', () => { ); test.each([ - { endTime: '2020-401T00:00:00.000', startTime: '2020-400T00:00:00.000' }, - { endTime: '2020-411T00:00:00.000', startTime: '2020-410T00:00:00.000' }, + { endTime: new Date('01-02-2030').getTime(), startTime: new Date('01-01-2030').getTime() }, + { endTime: new Date('01-01-2031').getTime(), startTime: new Date('01-01-2030').getTime() }, ])( 'Should not return an error for out of order start/end ($startTime/$endTime) times', async ({ endTime, startTime }) => { @@ -100,9 +99,8 @@ describe('validateEndTime', () => { const endTimeError = 'Simulation end must be after start'; test.each([ - { endTime: '2020-400T00:00:00.000', startTime: '2020-400T00:00:00.000' }, - { endTime: '2020-399T00:00:00.000', startTime: '2020-400T00:00:00.000' }, - { endTime: '2020-400T00:00:00.000', startTime: '2020-410T00:00:00.000' }, + { endTime: new Date('01-01-2030').getTime(), startTime: new Date('01-01-2030').getTime() }, + { endTime: new Date('01-01-2029').getTime(), startTime: new Date('01-01-2030').getTime() }, ])( 'Should return an error message for out of order start/end ($startTime/$endTime) times', async ({ endTime, startTime }) => { @@ -111,8 +109,8 @@ describe('validateEndTime', () => { ); test.each([ - { endTime: '2020-401T00:00:00.000', startTime: '2020-400T00:00:00.000' }, - { endTime: '2020-411T00:00:00.000', startTime: '2020-410T00:00:00.000' }, + { endTime: new Date('01-02-2030').getTime(), startTime: new Date('01-01-2030').getTime() }, + { endTime: new Date('01-01-2031').getTime(), startTime: new Date('01-01-2030').getTime() }, ])( 'Should not return an error for out of order start/end ($startTime/$endTime) times', async ({ endTime, startTime }) => { diff --git a/src/utilities/validators.ts b/src/utilities/validators.ts index 833909dc9a..8b26feb74e 100644 --- a/src/utilities/validators.ts +++ b/src/utilities/validators.ts @@ -70,18 +70,18 @@ export function hex(value: string): Promise { }); } -export function validateStartTime(startTime: string, endTime: string, type: string): Promise { +export function validateStartTime(startTimeMs: number, endTimeMs: number, type: string): Promise { return new Promise(resolve => { - if (startTime >= endTime) { + if (startTimeMs >= endTimeMs) { return resolve(`${type} start must be before end`); } return resolve(null); }); } -export function validateEndTime(startTime: string, endTime: string, type: string): Promise { +export function validateEndTime(startTimeMs: number, endTimeMs: number, type: string): Promise { return new Promise(resolve => { - if (endTime <= startTime) { + if (endTimeMs <= startTimeMs) { return resolve(`${type} end must be after start`); } return resolve(null); @@ -97,6 +97,7 @@ export async function validateField(field: Field): Promise { if (error !== null) { errors.push(error); + break; } } diff --git a/src/utilities/view.ts b/src/utilities/view.ts index f953f8aacd..137bc7918d 100644 --- a/src/utilities/view.ts +++ b/src/utilities/view.ts @@ -285,22 +285,6 @@ export function generateDefaultView(activityTypes: ActivityType[] = [], resource resizable: true, sortable: true, }, - { - field: 'derived_start_time', - filter: 'text', - headerName: 'Absolute Start Time (UTC)', - hide: false, - resizable: true, - sortable: true, - }, - { - field: 'derived_end_time', - filter: 'text', - headerName: 'Absolute End Time (UTC)', - hide: false, - resizable: true, - sortable: true, - }, { field: 'duration', filter: 'text', @@ -351,13 +335,6 @@ export function generateDefaultView(activityTypes: ActivityType[] = [], resource resizable: true, sortable: true, }, - { - field: 'derived_start_time', - filter: 'text', - headerName: 'Absolute Time', - resizable: true, - sortable: true, - }, { field: 'start_offset', filter: 'text',