From d4264283770740f1bf9e16455d92523eaf19531e Mon Sep 17 00:00:00 2001 From: Robert Lamacraft Date: Mon, 10 Mar 2025 11:57:35 +0000 Subject: [PATCH 1/2] Organise code that operates on units and their utility functions. --- .../ui/src/Inventory/Sample/Fields/Expiry.js | 5 +- .../Fields/SpecifiedStorageTemperature.js | 27 +- .../Sample/Fields/StorageTemperature.js | 3 +- .../Sample/Fields/TemplateFields/Fields.js | 2 +- .../StorageTemperature/Buttons.test.js | 6 +- .../StorageTemperature/WhenDisabled.test.js | 2 +- .../WhenEnabledAndUnspecified.test.js | 6 +- .../Template/Fields/DefaultValueField.js | 2 +- .../Workspace/SimpleSearch/SimpleSearch.js | 2 +- .../webapp/ui/src/components/AppBar/index.js | 2 +- .../PublicPages/IdentifierPublicPage.js | 2 +- .../ui/src/stores/definitions/Sample.js | 6 +- .../webapp/ui/src/stores/definitions/Units.js | 359 +++++++++++++++++- .../__tests__/Units}/CommonUnit.test.js | 4 +- .../__tests__/Units}/getRelativeTime.test.js | 2 +- .../__tests__/Units}/msToHMS.test.js | 2 +- .../Units}/temperatureFromTo.test.js | 3 +- .../Units}/truncateIsoTimestamp.test.js | 2 +- .../ui/src/stores/models/MaterialsModel.js | 2 +- .../ui/src/stores/models/ResultCollection.js | 2 +- .../ui/src/stores/models/SampleModel.js | 4 +- .../webapp/ui/src/tinyMCE/pyrat/Filter.js | 2 +- src/main/webapp/ui/src/util/conversions.js | 253 ------------ 23 files changed, 404 insertions(+), 296 deletions(-) rename src/main/webapp/ui/src/{util/__tests__/conversions => stores/definitions/__tests__/Units}/CommonUnit.test.js (95%) rename src/main/webapp/ui/src/{util/__tests__/conversions => stores/definitions/__tests__/Units}/getRelativeTime.test.js (92%) rename src/main/webapp/ui/src/{util/__tests__/conversions => stores/definitions/__tests__/Units}/msToHMS.test.js (88%) rename src/main/webapp/ui/src/{util/__tests__/conversions => stores/definitions/__tests__/Units}/temperatureFromTo.test.js (85%) rename src/main/webapp/ui/src/{util/__tests__/conversions => stores/definitions/__tests__/Units}/truncateIsoTimestamp.test.js (97%) delete mode 100644 src/main/webapp/ui/src/util/conversions.js diff --git a/src/main/webapp/ui/src/Inventory/Sample/Fields/Expiry.js b/src/main/webapp/ui/src/Inventory/Sample/Fields/Expiry.js index 9fca8b3ab..310b432a8 100644 --- a/src/main/webapp/ui/src/Inventory/Sample/Fields/Expiry.js +++ b/src/main/webapp/ui/src/Inventory/Sample/Fields/Expiry.js @@ -4,7 +4,10 @@ import React, { type Node } from "react"; import { observer } from "mobx-react-lite"; import Alert from "@mui/material/Alert"; import { type HasEditableFields } from "../../../stores/definitions/Editable"; -import { todaysDate, truncateIsoTimestamp } from "../../../util/conversions"; +import { + todaysDate, + truncateIsoTimestamp, +} from "../../../stores/definitions/Units"; import DateField from "../../../components/Inputs/DateField"; import BatchFormField from "../../components/Inputs/BatchFormField"; diff --git a/src/main/webapp/ui/src/Inventory/Sample/Fields/SpecifiedStorageTemperature.js b/src/main/webapp/ui/src/Inventory/Sample/Fields/SpecifiedStorageTemperature.js index 85be707d9..23580545b 100644 --- a/src/main/webapp/ui/src/Inventory/Sample/Fields/SpecifiedStorageTemperature.js +++ b/src/main/webapp/ui/src/Inventory/Sample/Fields/SpecifiedStorageTemperature.js @@ -8,21 +8,22 @@ import React, { } from "react"; import { observer } from "mobx-react-lite"; import Box from "@mui/material/Box"; -import { type Temperature } from "../../../stores/definitions/Sample"; -import Grid from "@mui/material/Grid"; -import Select from "@mui/material/Select"; -import MenuItem from "@mui/material/MenuItem"; -import Button from "@mui/material/Button"; -import { withStyles } from "Styles"; import { + type Temperature, CELSIUS, KELVIN, FAHRENHEIT, ABSOLUTE_ZERO, LIQUID_NITROGEN, type TemperatureScale, + temperatureFromTo, + validateTemperature, } from "../../../stores/definitions/Units"; -import { temperatureFromTo, validateTemperature } from "../../../util/conversions"; +import Grid from "@mui/material/Grid"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import Button from "@mui/material/Button"; +import { withStyles } from "Styles"; import NumberField from "../../../components/Inputs/NumberField"; import InputAdornment from "@mui/material/InputAdornment"; import { FormLabel } from "@mui/material"; @@ -173,7 +174,8 @@ function SpecifiedStorageTemperature({ const newMaxTemp = { numericValue: newMax, unitId }; setTemperatures({ storageTempMin: newMinTemp, storageTempMax: newMaxTemp }); onErrorStateChange( - !validateTemperature(newMinTemp).isError || !validateTemperature(newMaxTemp).isError + !validateTemperature(newMinTemp).isError || + !validateTemperature(newMaxTemp).isError ); }; @@ -187,7 +189,8 @@ function SpecifiedStorageTemperature({ const newMaxTemp = { numericValue: newMax, unitId }; setTemperatures({ storageTempMin: newMinTemp, storageTempMax: newMaxTemp }); onErrorStateChange( - !validateTemperature(newMinTemp).isError || !validateTemperature(newMaxTemp).isError + !validateTemperature(newMinTemp).isError || + !validateTemperature(newMaxTemp).isError ); }; @@ -239,7 +242,8 @@ function SpecifiedStorageTemperature({ variant="outlined" size="small" error={ - validateTemperature({ numericValue: min, unitId }).isError + validateTemperature({ numericValue: min, unitId }) + .isError } fullWidth InputProps={{ @@ -260,7 +264,8 @@ function SpecifiedStorageTemperature({ variant="outlined" size="small" error={ - validateTemperature({ numericValue: max, unitId }).isError + validateTemperature({ numericValue: max, unitId }) + .isError } fullWidth InputProps={{ diff --git a/src/main/webapp/ui/src/Inventory/Sample/Fields/StorageTemperature.js b/src/main/webapp/ui/src/Inventory/Sample/Fields/StorageTemperature.js index 73740afbb..026a341c5 100644 --- a/src/main/webapp/ui/src/Inventory/Sample/Fields/StorageTemperature.js +++ b/src/main/webapp/ui/src/Inventory/Sample/Fields/StorageTemperature.js @@ -2,11 +2,10 @@ import React, { type Node } from "react"; import { observer } from "mobx-react-lite"; -import { type Temperature } from "../../../stores/definitions/Sample"; +import { type Temperature, CELSIUS } from "../../../stores/definitions/Units"; import { type HasEditableFields } from "../../../stores/definitions/Editable"; import Button from "@mui/material/Button"; import SpecifiedStorageTemperature from "./SpecifiedStorageTemperature"; -import { CELSIUS } from "../../../stores/definitions/Units"; import BatchFormField from "../../components/Inputs/BatchFormField"; function StorageTemperature< diff --git a/src/main/webapp/ui/src/Inventory/Sample/Fields/TemplateFields/Fields.js b/src/main/webapp/ui/src/Inventory/Sample/Fields/TemplateFields/Fields.js index 0d360e7b8..de358fdb0 100644 --- a/src/main/webapp/ui/src/Inventory/Sample/Fields/TemplateFields/Fields.js +++ b/src/main/webapp/ui/src/Inventory/Sample/Fields/TemplateFields/Fields.js @@ -8,7 +8,7 @@ import FormField from "../../../components/Inputs/FormField"; import AttachmentField from "../../../../components/Inputs/AttachmentField"; import ChoiceField from "../../../../components/Inputs/ChoiceField"; import DateField from "../../../../components/Inputs/DateField"; -import { truncateIsoTimestamp } from "../../../../util/conversions"; +import { truncateIsoTimestamp } from "../../../../stores/definitions/Units"; import NumberField from "../../../../components/Inputs/NumberField"; import RadioField from "../../../../components/Inputs/RadioField"; import ReferenceField from "../../../../components/Inputs/ReferenceField"; diff --git a/src/main/webapp/ui/src/Inventory/Sample/Fields/__tests__/StorageTemperature/Buttons.test.js b/src/main/webapp/ui/src/Inventory/Sample/Fields/__tests__/StorageTemperature/Buttons.test.js index 986f0556b..8e9845ee8 100644 --- a/src/main/webapp/ui/src/Inventory/Sample/Fields/__tests__/StorageTemperature/Buttons.test.js +++ b/src/main/webapp/ui/src/Inventory/Sample/Fields/__tests__/StorageTemperature/Buttons.test.js @@ -6,12 +6,14 @@ import React from "react"; import { render, cleanup } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { CELSIUS } from "../../../../../stores/definitions/Units"; +import { + CELSIUS, + type Temperature, +} from "../../../../../stores/definitions/Units"; import StorageTemperature from "../../StorageTemperature"; import Button from "@mui/material/Button"; import { ThemeProvider } from "@mui/material/styles"; import materialTheme from "../../../../../theme"; -import { type Temperature } from "../../../../../stores/definitions/Sample"; jest.mock("@mui/material/Button", () => jest.fn(() => <>)); diff --git a/src/main/webapp/ui/src/Inventory/Sample/Fields/__tests__/StorageTemperature/WhenDisabled.test.js b/src/main/webapp/ui/src/Inventory/Sample/Fields/__tests__/StorageTemperature/WhenDisabled.test.js index 98ea6a4af..6f3325a38 100644 --- a/src/main/webapp/ui/src/Inventory/Sample/Fields/__tests__/StorageTemperature/WhenDisabled.test.js +++ b/src/main/webapp/ui/src/Inventory/Sample/Fields/__tests__/StorageTemperature/WhenDisabled.test.js @@ -10,12 +10,12 @@ import { CELSIUS, KELVIN, FAHRENHEIT, + type Temperature, } from "../../../../../stores/definitions/Units"; import { ThemeProvider } from "@mui/material/styles"; import materialTheme from "../../../../../theme"; import StorageTemperature from "../../StorageTemperature"; -import { type Temperature } from "../../../../../stores/definitions/Sample"; beforeEach(() => { jest.clearAllMocks(); diff --git a/src/main/webapp/ui/src/Inventory/Sample/Fields/__tests__/StorageTemperature/WhenEnabledAndUnspecified.test.js b/src/main/webapp/ui/src/Inventory/Sample/Fields/__tests__/StorageTemperature/WhenEnabledAndUnspecified.test.js index 7d81044e9..0419d273c 100644 --- a/src/main/webapp/ui/src/Inventory/Sample/Fields/__tests__/StorageTemperature/WhenEnabledAndUnspecified.test.js +++ b/src/main/webapp/ui/src/Inventory/Sample/Fields/__tests__/StorageTemperature/WhenEnabledAndUnspecified.test.js @@ -7,10 +7,12 @@ import React from "react"; import { render, cleanup, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import StorageTemperature from "../../StorageTemperature"; -import { CELSIUS } from "../../../../../stores/definitions/Units"; +import { + CELSIUS, + type Temperature, +} from "../../../../../stores/definitions/Units"; import { ThemeProvider } from "@mui/material/styles"; import materialTheme from "../../../../../theme"; -import { type Temperature } from "../../../../../stores/definitions/Sample"; import userEvent from "@testing-library/user-event"; const mockFieldOwner = (mockedParts: {| diff --git a/src/main/webapp/ui/src/Inventory/Template/Fields/DefaultValueField.js b/src/main/webapp/ui/src/Inventory/Template/Fields/DefaultValueField.js index b193c6132..36cacb1fb 100644 --- a/src/main/webapp/ui/src/Inventory/Template/Fields/DefaultValueField.js +++ b/src/main/webapp/ui/src/Inventory/Template/Fields/DefaultValueField.js @@ -11,7 +11,7 @@ import InputWrapper from "../../../components/Inputs/InputWrapper"; import Button from "@mui/material/Button"; import AddIcon from "@mui/icons-material/Add"; import { makeStyles } from "tss-react/mui"; -import { truncateIsoTimestamp } from "../../../util/conversions"; +import { truncateIsoTimestamp } from "../../../stores/definitions/Units"; const useStyles = makeStyles()((theme) => ({ buttonWrapper: { diff --git a/src/main/webapp/ui/src/Toolbar/Workspace/SimpleSearch/SimpleSearch.js b/src/main/webapp/ui/src/Toolbar/Workspace/SimpleSearch/SimpleSearch.js index b01af4bc3..75f6c3556 100644 --- a/src/main/webapp/ui/src/Toolbar/Workspace/SimpleSearch/SimpleSearch.js +++ b/src/main/webapp/ui/src/Toolbar/Workspace/SimpleSearch/SimpleSearch.js @@ -15,7 +15,7 @@ import DateField from "../../../components/Inputs/DateField"; import { library } from "@fortawesome/fontawesome-svg-core"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faFilter, faSearch, faBars } from "@fortawesome/free-solid-svg-icons"; -import { truncateIsoTimestamp } from "../../../util/conversions"; +import { truncateIsoTimestamp } from "../../../stores/definitions/Units"; library.add(faFilter, faSearch, faBars); import UserSelect from "../AdvancedSearch/UserSelect/UserSelect"; diff --git a/src/main/webapp/ui/src/components/AppBar/index.js b/src/main/webapp/ui/src/components/AppBar/index.js index 9414b4c31..755d477fa 100644 --- a/src/main/webapp/ui/src/components/AppBar/index.js +++ b/src/main/webapp/ui/src/components/AppBar/index.js @@ -54,7 +54,7 @@ import CircularProgress from "@mui/material/CircularProgress"; import MaintenanceIcon from "@mui/icons-material/Construction"; import Popover from "@mui/material/Popover"; import IconButton from "@mui/material/IconButton"; -import { getRelativeTime } from "../../util/conversions"; +import { getRelativeTime } from "../../stores/definitions/Units"; import Result from "../../util/result"; import useSessionStorage from "../../util/useSessionStorage"; diff --git a/src/main/webapp/ui/src/components/PublicPages/IdentifierPublicPage.js b/src/main/webapp/ui/src/components/PublicPages/IdentifierPublicPage.js index 1b3bcc8bc..d2b9db41b 100644 --- a/src/main/webapp/ui/src/components/PublicPages/IdentifierPublicPage.js +++ b/src/main/webapp/ui/src/components/PublicPages/IdentifierPublicPage.js @@ -54,7 +54,7 @@ import TableRow from "@mui/material/TableRow"; import NoValue from "../NoValue"; import VisuallyHiddenHeading from "../VisuallyHiddenHeading"; import IdentifierModel from "../../stores/models/IdentifierModel"; -import { truncateIsoTimestamp } from "../../util/conversions"; +import { truncateIsoTimestamp } from "../../stores/definitions/Units"; const useStyles = makeStyles()((theme) => ({ styledDescriptionList: { diff --git a/src/main/webapp/ui/src/stores/definitions/Sample.js b/src/main/webapp/ui/src/stores/definitions/Sample.js index 54b247c1a..dfaae4859 100644 --- a/src/main/webapp/ui/src/stores/definitions/Sample.js +++ b/src/main/webapp/ui/src/stores/definitions/Sample.js @@ -10,15 +10,11 @@ import { type InventoryRecord } from "./InventoryRecord"; import { type Template } from "./Template"; import { type Id } from "./BaseRecord"; import { type Field } from "./Field"; +import { type Temperature } from "./Units"; export type Alias = {| alias: string, plural: string |}; export type SampleSource = "LAB_CREATED" | "VENDOR_SUPPLIED" | "OTHER"; -export type Temperature = {| - numericValue: number, - unitId: number, -|}; - export interface Sample extends InventoryRecord { template: ?Template; storageTempMin: ?Temperature; diff --git a/src/main/webapp/ui/src/stores/definitions/Units.js b/src/main/webapp/ui/src/stores/definitions/Units.js index b9b6fc38e..14fcd2ef9 100644 --- a/src/main/webapp/ui/src/stores/definitions/Units.js +++ b/src/main/webapp/ui/src/stores/definitions/Units.js @@ -1,13 +1,370 @@ //@flow +import { match } from "../../util/Util"; +import Result from "../../util/result"; +import * as Parsers from "../../util/parsers"; + +/** + * @module + * @desc Scientists often record data in different units of measure. This module + * defines enums for these different types of units, types and objects for + * model quantities of those units, and functions for converting between. + */ + +/** + * First, enums for different categories of units of measure, and objects that model + * some quantity of those units. + */ + +/** + * Enum value for quantities that do not have a unit and come in discrete + * amounts e.g. rock samples. The associated QuantityValue should be a positive + * integer. + */ +export const unitlessIds = { + items: 1, +}; + +/** + * Enum values for units of quantities measured in volume. + */ +export const volumeIds = { + microliters: 2, + milliliters: 3, + liters: 4, + picoliters: 18, + nanoliters: 19, + millimeterscubed: 23, + centimeterscubed: 24, + decimeterscubed: 25, + meterscubed: 26, +}; + +/** + * Enum values for units of quantities measured in mass. + */ +export const massIds = { + micrograms: 5, + milligrams: 6, + grams: 7, + picograms: 20, + nanograms: 21, + kilograms: 22, +}; + +/** + * Enum values for all types of quantities. + */ +export const quantityIds = { + ...volumeIds, + ...massIds, + ...unitlessIds, +}; + +/** + * The numerical amount of a particular quantity. + */ +export type QuantityValue = number; + +/** + * The unit associated with a particular quantity. + */ +export type QuantityUnitId = $Values; + +/** + * Checks where a quantity's unit is one that measures volume. + */ +const isVolume = (q: QuantityUnitId) => Object.values(volumeIds).includes(q); + +/** + * Checks where a quantity's unit is one that measures mass. + */ +const isMass = (q: QuantityUnitId) => Object.values(massIds).includes(q); + +/** + * Checks where a quantity's unit is one that comes in a discrete amount. + */ +const isUnitless = (q: QuantityUnitId) => + Object.values(unitlessIds).includes(q); + +/** + * For each category of quantity, there is a smallest unit of that category + * that RSpace supports. This function returns that unit for any given unit of + * the same category e.g. for volume, the smallest unit is picoliters. + */ +const atomicUnitOfSameCategory = (q: QuantityUnitId) => + match QuantityUnitId>([ + [() => isVolume(q), () => quantityIds.picoliters], + [() => isMass(q), () => quantityIds.picograms], + [() => isUnitless(q), () => quantityIds.items], + [ + () => true, + () => { + throw new Error(`Unknown unit: ${q}`); + }, + ], + ])()(); + +/** + * For each unit, this object stores the power of 1000 by which the unit is + * greater than the atomic unit of the same unit category e.g. grams are 1000^4 times greater than picograms. + */ +const quantityUnitMagnitudes = { + [unitlessIds.items]: 0, + [volumeIds.picoliters]: 0, + [volumeIds.nanoliters]: 1, + [volumeIds.microliters]: 2, + [volumeIds.milliliters]: 3, + [volumeIds.liters]: 4, + [volumeIds.millimeterscubed]: 2, + [volumeIds.centimeterscubed]: 3, + [volumeIds.decimeterscubed]: 4, + [volumeIds.meterscubed]: 5, + [massIds.picograms]: 0, + [massIds.nanograms]: 1, + [massIds.micrograms]: 2, + [massIds.milligrams]: 3, + [massIds.grams]: 4, + [massIds.kilograms]: 5, +}; + +/** + * Converts a quantity value from a given unit to the atomic unit of the same + * category. + * @arg value The quantity value to convert e.g. 4 + * @arg id The unit to convert from e.g. massIds.grams + * @returns The converted quantity value e.g. 4,000,000,000 + * + * Be very careful when using this function, as it can easily lead to values + * that are greater than Number.MAX_SAFE_INTEGER. + */ +export function toCommonUnit(value: QuantityValue, id: QuantityUnitId): number { + const baseId = atomicUnitOfSameCategory(id); + const gap = quantityUnitMagnitudes[id] - quantityUnitMagnitudes[baseId]; + return value * Math.pow(1000, gap); +} + +/** + * Converts a quantity value from the atomic unit of the same category to a given + * unit. + * @arg value The quantity value to convert e.g. 4,000,000,000 + * @arg id The unit to convert to e.g. massIds.grams + * @returns The converted quantity value e.g. 4 + */ +export function fromCommonUnit( + value: QuantityValue, + id: QuantityUnitId +): number { + const baseId = atomicUnitOfSameCategory(id); + const gap = quantityUnitMagnitudes[id] - quantityUnitMagnitudes[baseId]; + return value / Math.pow(1000, gap); +} + +/** + * Specifics for working with dates and times + */ + +/** + * Converts a number of milliseconds to a string in the format HH:MM:SS. + */ +export function msToHMS(ms: number): string { + let seconds = ms / 1000; + const hours = parseInt(seconds / 3600, 10); + seconds = seconds % 3600; + const minutes = parseInt(seconds / 60, 10); + seconds = seconds % 60; + const hmsFormatted = `${hours < 10 ? "0" : ""}${hours}:${ + minutes < 10 ? "0" : "" + }${minutes}:${seconds < 10 ? "0" : ""}${parseInt(seconds, 10)}`; + return hmsFormatted; +} + +/** + * Converts a number of milliseconds to a number of days + */ +export function msToDays(ms: number): number { + return ms / (1000 * 60 * 60 * 24); +} + +type DatePrecision = + | "year" + | "month" + | "date" + | "hour" + | "minute" + | "second" + | "millisecond"; + +/** + * This function outputs the prefix of a ISO timestamp. + * The first argument is a Date object or any string that the JS runtime can + * parse as a date (best to use ISO timestamp to play it safe). The second + * argument specifies the length of the prefix based to the level of precision + * that should be encoded in the output string. + */ +export function truncateIsoTimestamp( + isoTimestamp: string | Date, + precision: DatePrecision +): Result { + const date = new Date(isoTimestamp); + if (date.toString() === "Invalid Date") + return Result.Error([new Error("Invalid Date")]); + let output = ""; + switch (precision) { + case "millisecond": + output = `.${date.getMilliseconds().toString().padStart(3, "0")}`; + // falls through + case "second": + output = `:${date.getSeconds().toString().padStart(2, "0")}${output}`; + // falls through + case "minute": + output = `:${date.getMinutes().toString().padStart(2, "0")}${output}`; + // falls through + case "hour": + output = `T${date.getHours().toString().padStart(2, "0")}${output}`; + // falls through + case "date": + output = `-${date.getDate().toString().padStart(2, "0")}${output}`; + // falls through + case "month": + output = `-${(date.getMonth() + 1).toString().padStart(2, "0")}${output}`; + // falls through + case "year": + output = `${date.getFullYear()}${output}`; + } + return Result.Ok(output); +} + +/** + * Gets today's date without the time component. + */ +export function todaysDate(): Date { + return new Date( + truncateIsoTimestamp(new Date(), "date").orElseGet(() => { + throw new Error("Impossible"); + // `new Date()` can't produce an invalid date + }) + ); +} + +/** + * Get the relative time from now to a target date, in terms of the largest + * unit of time that is smaller than the interval. + * + * @param targetDate The date in the future. + */ +export function getRelativeTime(targetDate: Date): string { + const now = new Date(); + const futureDate = targetDate; + //const diffInSeconds = Math.floor((futureDate - now) / 1000); + const diffInSeconds = Math.floor( + (futureDate.getTime() - now.getTime()) / 1000 + ); + + const units = [ + { name: "year", seconds: 60 * 60 * 24 * 365 }, + { name: "month", seconds: 60 * 60 * 24 * 30 }, + { name: "day", seconds: 60 * 60 * 24 }, + { name: "hour", seconds: 60 * 60 }, + { name: "minute", seconds: 60 }, + { name: "second", seconds: 1 }, + ]; + + for (const unit of units) { + if (Math.abs(diffInSeconds) >= unit.seconds) { + const value = Math.floor(diffInSeconds / unit.seconds); + //$FlowExpectedError[prop-missing] -- Flow doesn't know about Intl.RelativeTimeFormat + return new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format( + value, + unit.name + ); + } + } + + return "now"; +} + +/** + * Specifics for working with temperatures + */ + +/** + * Enum value for degrees Celsius. + */ export const CELSIUS = 8; + +/** + * Enum value for Kelvin. + */ export const KELVIN = 9; + +/** + * Enum value for degrees Fahrenheit. + */ export const FAHRENHEIT = 10; + +/** + * The type of any temperature scale. + */ export type TemperatureScale = | typeof CELSIUS | typeof KELVIN | typeof FAHRENHEIT; -// In celsius +/** + * A particular temperature, in a particular scale. + */ +export type Temperature = {| + numericValue: number, + unitId: TemperatureScale, +|}; + +/** + * Absolute zero in degrees Celsius. + */ export const ABSOLUTE_ZERO = -273; + +/** + * The boiling point of nitrogen in degrees Celsius. Useful in defining + * temperatures in labs because samples are often stored in liquid nitrogen at + * temperatures below this. + */ export const LIQUID_NITROGEN = -196; + +/** + * Convert a temperature from one scale to another. + */ +export function temperatureFromTo( + from: TemperatureScale, + to: TemperatureScale, + value: number +): number { + let valueInCelsius = value; + if (from === KELVIN) valueInCelsius = value - 273.15; + else if (from === FAHRENHEIT) valueInCelsius = (value - 32) / 1.8; + if (to === CELSIUS) return Math.round(valueInCelsius); + if (to === KELVIN) return Math.round(valueInCelsius + 273.15); + return Math.round(valueInCelsius * 1.8 + 32); +} + +/** + * If a non-null value is passed, then it is check to be not NaN and not less + * than absolute zero. + */ +export const validateTemperature = (temp: ?Temperature): Result => + Result.first( + !temp ? Result.Ok(null) : Result.Error([]), + Parsers.isNotBottom(temp) + .flatMap((t) => + Parsers.isNotNaN(t.numericValue).map((numericValue) => ({ + numericValue, + ...t, + })) + ) + .mapError(() => new Error("Temperature is invalid")) + .flatMap((t) => + t.numericValue >= temperatureFromTo(CELSIUS, t.unitId, ABSOLUTE_ZERO) + ? Result.Ok(null) + : Result.Error([new Error("Temperature is less than absolute zero")]) + ) + ); diff --git a/src/main/webapp/ui/src/util/__tests__/conversions/CommonUnit.test.js b/src/main/webapp/ui/src/stores/definitions/__tests__/Units/CommonUnit.test.js similarity index 95% rename from src/main/webapp/ui/src/util/__tests__/conversions/CommonUnit.test.js rename to src/main/webapp/ui/src/stores/definitions/__tests__/Units/CommonUnit.test.js index aefa5520e..8d7b82347 100644 --- a/src/main/webapp/ui/src/util/__tests__/conversions/CommonUnit.test.js +++ b/src/main/webapp/ui/src/stores/definitions/__tests__/Units/CommonUnit.test.js @@ -9,9 +9,9 @@ import { massIds, fromCommonUnit, quantityIds, -} from "../../conversions"; +} from "../../Units"; import fc from "fast-check"; -import { values } from "../../Util"; +import { values } from "../../../../util/Util"; // number of decimal places for floating point comparison const PRECISION = 5; diff --git a/src/main/webapp/ui/src/util/__tests__/conversions/getRelativeTime.test.js b/src/main/webapp/ui/src/stores/definitions/__tests__/Units/getRelativeTime.test.js similarity index 92% rename from src/main/webapp/ui/src/util/__tests__/conversions/getRelativeTime.test.js rename to src/main/webapp/ui/src/stores/definitions/__tests__/Units/getRelativeTime.test.js index ee762d112..56a0c8169 100644 --- a/src/main/webapp/ui/src/util/__tests__/conversions/getRelativeTime.test.js +++ b/src/main/webapp/ui/src/stores/definitions/__tests__/Units/getRelativeTime.test.js @@ -1,6 +1,6 @@ //@flow /* eslint-env jest */ -import { getRelativeTime } from "../../conversions"; +import { getRelativeTime } from "../../Units"; import fc from "fast-check"; describe("getRelativeTime", () => { diff --git a/src/main/webapp/ui/src/util/__tests__/conversions/msToHMS.test.js b/src/main/webapp/ui/src/stores/definitions/__tests__/Units/msToHMS.test.js similarity index 88% rename from src/main/webapp/ui/src/util/__tests__/conversions/msToHMS.test.js rename to src/main/webapp/ui/src/stores/definitions/__tests__/Units/msToHMS.test.js index c43b5659e..bf9e5aba9 100644 --- a/src/main/webapp/ui/src/util/__tests__/conversions/msToHMS.test.js +++ b/src/main/webapp/ui/src/stores/definitions/__tests__/Units/msToHMS.test.js @@ -1,7 +1,7 @@ //@flow /* eslint-env jest */ import fc from "fast-check"; -import { msToHMS } from "../../conversions"; +import { msToHMS } from "../../Units"; describe("msToHMS", () => { test("Input of less than a day should output format of a series of 2 digits", () => { diff --git a/src/main/webapp/ui/src/util/__tests__/conversions/temperatureFromTo.test.js b/src/main/webapp/ui/src/stores/definitions/__tests__/Units/temperatureFromTo.test.js similarity index 85% rename from src/main/webapp/ui/src/util/__tests__/conversions/temperatureFromTo.test.js rename to src/main/webapp/ui/src/stores/definitions/__tests__/Units/temperatureFromTo.test.js index e9825bb00..a2886dcfc 100644 --- a/src/main/webapp/ui/src/util/__tests__/conversions/temperatureFromTo.test.js +++ b/src/main/webapp/ui/src/stores/definitions/__tests__/Units/temperatureFromTo.test.js @@ -4,8 +4,7 @@ //@flow /* eslint-env jest */ import "@testing-library/jest-dom"; -import { temperatureFromTo } from "../../conversions"; -import { CELSIUS, KELVIN, FAHRENHEIT } from "../../../stores/definitions/Units"; +import { temperatureFromTo, CELSIUS, KELVIN, FAHRENHEIT } from "../../Units"; describe("conversions", () => { describe("Simple examples", () => { diff --git a/src/main/webapp/ui/src/util/__tests__/conversions/truncateIsoTimestamp.test.js b/src/main/webapp/ui/src/stores/definitions/__tests__/Units/truncateIsoTimestamp.test.js similarity index 97% rename from src/main/webapp/ui/src/util/__tests__/conversions/truncateIsoTimestamp.test.js rename to src/main/webapp/ui/src/stores/definitions/__tests__/Units/truncateIsoTimestamp.test.js index efe8f00b9..adf6894e1 100644 --- a/src/main/webapp/ui/src/util/__tests__/conversions/truncateIsoTimestamp.test.js +++ b/src/main/webapp/ui/src/stores/definitions/__tests__/Units/truncateIsoTimestamp.test.js @@ -4,7 +4,7 @@ //@flow /* eslint-env jest */ import "@testing-library/jest-dom"; -import { truncateIsoTimestamp } from "../../conversions"; +import { truncateIsoTimestamp } from "../../Units"; describe("truncateIsoTimestamp", () => { test("Simple examples with string", () => { diff --git a/src/main/webapp/ui/src/stores/models/MaterialsModel.js b/src/main/webapp/ui/src/stores/models/MaterialsModel.js index 7a259a3d3..f68cf021f 100644 --- a/src/main/webapp/ui/src/stores/models/MaterialsModel.js +++ b/src/main/webapp/ui/src/stores/models/MaterialsModel.js @@ -2,7 +2,7 @@ import InvApiService from "../../common/InvApiService"; import * as ArrayUtils from "../../util/ArrayUtils"; -import { toCommonUnit, fromCommonUnit } from "../../util/conversions"; +import { toCommonUnit, fromCommonUnit } from "../definitions/Units"; import RsSet from "../../util/set"; import ContainerModel, { type ContainerAttrs } from "../models/ContainerModel"; import SubSampleModel, { type SubSampleAttrs } from "../models/SubSampleModel"; diff --git a/src/main/webapp/ui/src/stores/models/ResultCollection.js b/src/main/webapp/ui/src/stores/models/ResultCollection.js index 5757667ec..fc6a306e1 100644 --- a/src/main/webapp/ui/src/stores/models/ResultCollection.js +++ b/src/main/webapp/ui/src/stores/models/ResultCollection.js @@ -5,7 +5,7 @@ import { action, observable, makeObservable, computed, override } from "mobx"; import { match } from "../../util/Util"; import * as ArrayUtils from "../../util/ArrayUtils"; import RsSet, { flattenWithIntersectionWithEq } from "../../util/set"; -import { truncateIsoTimestamp } from "../../util/conversions"; +import { truncateIsoTimestamp } from "../definitions/Units"; import { type HasEditableFields, } from "../definitions/Editable"; diff --git a/src/main/webapp/ui/src/stores/models/SampleModel.js b/src/main/webapp/ui/src/stores/models/SampleModel.js index d824c4440..0fac6fc4c 100644 --- a/src/main/webapp/ui/src/stores/models/SampleModel.js +++ b/src/main/webapp/ui/src/stores/models/SampleModel.js @@ -59,12 +59,10 @@ import { type Template } from "../definitions/Template"; import { type Attachment } from "../definitions/Attachment"; import { type Sample, - type Temperature, type Alias, type SampleSource, } from "../definitions/Sample"; -import { CELSIUS } from "../definitions/Units"; -import { validateTemperature } from "../../util/conversions"; +import { CELSIUS, type Temperature, validateTemperature } from "../definitions/Units"; import SampleIllustration from "../../assets/graphics/RecordTypeGraphics/HeaderIllustrations/Sample"; import React, { type Node } from "react"; import { type BarcodeAttrs } from "../definitions/Barcode"; diff --git a/src/main/webapp/ui/src/tinyMCE/pyrat/Filter.js b/src/main/webapp/ui/src/tinyMCE/pyrat/Filter.js index be9efeb86..549c3e9af 100644 --- a/src/main/webapp/ui/src/tinyMCE/pyrat/Filter.js +++ b/src/main/webapp/ui/src/tinyMCE/pyrat/Filter.js @@ -11,7 +11,7 @@ import Autocomplete from "@mui/material/Autocomplete"; import { stableSort } from "../../util/table"; import Grid from "@mui/material/Grid"; import DateField2 from "../../components/Inputs/DateField"; -import { truncateIsoTimestamp } from "../../util/conversions"; +import { truncateIsoTimestamp } from "../../stores/definitions/Units"; const useStyles = makeStyles()(() => ({ button: { diff --git a/src/main/webapp/ui/src/util/conversions.js b/src/main/webapp/ui/src/util/conversions.js deleted file mode 100644 index 20a833b30..000000000 --- a/src/main/webapp/ui/src/util/conversions.js +++ /dev/null @@ -1,253 +0,0 @@ -// @flow -import { match } from "./Util"; -import { - type TemperatureScale, - CELSIUS, - KELVIN, - ABSOLUTE_ZERO, -} from "../stores/definitions/Units"; -import { type Temperature } from "../stores/definitions/Sample"; -import Result from "./result"; -import * as Parsers from "./parsers"; - -/* quantity conversions */ - -export const unitlessIds = { - items: 1, -}; - -export const volumeIds = { - microliters: 2, - milliliters: 3, - liters: 4, - picoliters: 18, - nanoliters: 19, - millimeterscubed: 23, - centimeterscubed: 24, - decimeterscubed: 25, - meterscubed: 26, -}; - -export const massIds = { - micrograms: 5, - milligrams: 6, - grams: 7, - picograms: 20, - nanograms: 21, - kilograms: 22, -}; - -export const quantityIds = { - ...volumeIds, - ...massIds, - ...unitlessIds, -}; - -export type QuantityValue = number; -export type QuantityUnitId = $Values; - -const isVolume = (q: QuantityUnitId) => Object.values(volumeIds).includes(q); - -const isMass = (q: QuantityUnitId) => Object.values(massIds).includes(q); - -const isUnitless = (q: QuantityUnitId) => - Object.values(unitlessIds).includes(q); - -const atomicUnitOfSameCategory = (q: QuantityUnitId) => - match QuantityUnitId>([ - [() => isVolume(q), () => quantityIds.picoliters], - [() => isMass(q), () => quantityIds.picograms], - [() => isUnitless(q), () => quantityIds.items], - [ - () => true, - () => { - throw new Error(`Unknown unit: ${q}`); - }, - ], - ])()(); - -const quantityUnitMagnitudes = { - [unitlessIds.items]: 0, - [volumeIds.picoliters]: 0, - [volumeIds.nanoliters]: 1, - [volumeIds.microliters]: 2, - [volumeIds.milliliters]: 3, - [volumeIds.liters]: 4, - [volumeIds.millimeterscubed]: 2, - [volumeIds.centimeterscubed]: 3, - [volumeIds.decimeterscubed]: 4, - [volumeIds.meterscubed]: 5, - [massIds.picograms]: 0, - [massIds.nanograms]: 1, - [massIds.micrograms]: 2, - [massIds.milligrams]: 3, - [massIds.grams]: 4, - [massIds.kilograms]: 5, -}; - -export function toCommonUnit(value: QuantityValue, id: QuantityUnitId): number { - const baseId = atomicUnitOfSameCategory(id); - const gap = quantityUnitMagnitudes[id] - quantityUnitMagnitudes[baseId]; - return value * Math.pow(1000, gap); -} - -export function fromCommonUnit( - value: QuantityValue, - id: QuantityUnitId -): number { - const baseId = atomicUnitOfSameCategory(id); - const gap = quantityUnitMagnitudes[id] - quantityUnitMagnitudes[baseId]; - return value / Math.pow(1000, gap); -} - -/* time conversions */ - -export function msToHMS(ms: number): string { - let seconds = ms / 1000; - const hours = parseInt(seconds / 3600); - seconds = seconds % 3600; - const minutes = parseInt(seconds / 60); - seconds = seconds % 60; - const hmsFormatted = `${hours < 10 ? "0" : ""}${hours}:${ - minutes < 10 ? "0" : "" - }${minutes}:${seconds < 10 ? "0" : ""}${parseInt(seconds)}`; - return hmsFormatted; -} - -export function msToDays(ms: number): number { - return ms / (1000 * 60 * 60 * 24); -} - -type DatePrecision = - | "year" - | "month" - | "date" - | "hour" - | "minute" - | "second" - | "millisecond"; - -/* - * This function outputs the prefix of a ISO timestamp. - * The first argument is a Date object or any string that the JS runtime can - * parse as a date (best to use ISO timestamp to play it safe). The second - * argument specifies the length of the prefix based to the level of precision - * that should be encoded in the output string. - */ -export function truncateIsoTimestamp( - isoTimestamp: string | Date, - precision: DatePrecision -): Result { - const date = new Date(isoTimestamp); - if (date.toString() === "Invalid Date") - return Result.Error([new Error("Invalid Date")]); - let output = ""; - switch (precision) { - case "millisecond": - output = `.${date.getMilliseconds().toString().padStart(3, "0")}`; - // falls through - case "second": - output = `:${date.getSeconds().toString().padStart(2, "0")}${output}`; - // falls through - case "minute": - output = `:${date.getMinutes().toString().padStart(2, "0")}${output}`; - // falls through - case "hour": - output = `T${date.getHours().toString().padStart(2, "0")}${output}`; - // falls through - case "date": - output = `-${date.getDate().toString().padStart(2, "0")}${output}`; - // falls through - case "month": - output = `-${(date.getMonth() + 1).toString().padStart(2, "0")}${output}`; - // falls through - case "year": - output = `${date.getFullYear()}${output}`; - } - return Result.Ok(output); -} - -export function todaysDate(): Date { - return new Date( - truncateIsoTimestamp(new Date(), "date").orElseGet(() => { - throw new Error("Impossible"); - // `new Date()` can't produce an invalid date - }) - ); -} - -/** - * Get the relative time from now to a target date, in terms of the largest - * unit of time that is smaller than the interval. - * - * @param targetDate The date in the future. - */ -export function getRelativeTime(targetDate: Date): string { - const now = new Date(); - const futureDate = targetDate; - //const diffInSeconds = Math.floor((futureDate - now) / 1000); - const diffInSeconds = Math.floor( - (futureDate.getTime() - now.getTime()) / 1000 - ); - - const units = [ - { name: "year", seconds: 60 * 60 * 24 * 365 }, - { name: "month", seconds: 60 * 60 * 24 * 30 }, - { name: "day", seconds: 60 * 60 * 24 }, - { name: "hour", seconds: 60 * 60 }, - { name: "minute", seconds: 60 }, - { name: "second", seconds: 1 }, - ]; - - for (const unit of units) { - if (Math.abs(diffInSeconds) >= unit.seconds) { - const value = Math.floor(diffInSeconds / unit.seconds); - //$FlowExpectedError[prop-missing] -- Flow doesn't know about Intl.RelativeTimeFormat - return new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format( - value, - unit.name - ); - } - } - - return "now"; -} - -/* temperature conversions */ - -export function temperatureFromTo( - from: TemperatureScale, - to: TemperatureScale, - value: number -): number { - // prettier-ignore - const valueInCelsius = - from === CELSIUS ? value : - from === KELVIN ? value - 273.15 : - /* else FAHRENHEIT */ (value - 32) / 1.8; - // prettier-ignore - const valueInNewUnit = Math.round( - to === CELSIUS ? valueInCelsius : - to === KELVIN ? valueInCelsius + 273.15 : - /* else FAHRENHEIT */ valueInCelsius * 1.8 + 32 - ); - return valueInNewUnit; -} - -export const validateTemperature = (t: ?Temperature): Result => - Result.first( - !t ? Result.Ok(null) : Result.Error([]), - Parsers.isNotBottom(t) - .flatMap((t) => - Parsers.isNotNaN(t.numericValue).map((numericValue) => ({ - numericValue, - ...t, - })) - ) - .mapError(() => new Error("Temperature is invalid")) - .flatMap((t) => - t.numericValue >= temperatureFromTo(CELSIUS, t.unitId, ABSOLUTE_ZERO) - ? Result.Ok(null) - : Result.Error([new Error("Temperature is less than absolute zero")]) - ) - ); From a545ca1eda1bbb76633c0d688afe05a632002201 Mon Sep 17 00:00:00 2001 From: Robert Lamacraft Date: Mon, 10 Mar 2025 15:11:12 +0000 Subject: [PATCH 2/2] Delete msToHMS; an unused function --- .../webapp/ui/src/stores/definitions/Units.js | 15 --------------- .../definitions/__tests__/Units/msToHMS.test.js | 14 -------------- 2 files changed, 29 deletions(-) delete mode 100644 src/main/webapp/ui/src/stores/definitions/__tests__/Units/msToHMS.test.js diff --git a/src/main/webapp/ui/src/stores/definitions/Units.js b/src/main/webapp/ui/src/stores/definitions/Units.js index 14fcd2ef9..738a23a13 100644 --- a/src/main/webapp/ui/src/stores/definitions/Units.js +++ b/src/main/webapp/ui/src/stores/definitions/Units.js @@ -164,21 +164,6 @@ export function fromCommonUnit( * Specifics for working with dates and times */ -/** - * Converts a number of milliseconds to a string in the format HH:MM:SS. - */ -export function msToHMS(ms: number): string { - let seconds = ms / 1000; - const hours = parseInt(seconds / 3600, 10); - seconds = seconds % 3600; - const minutes = parseInt(seconds / 60, 10); - seconds = seconds % 60; - const hmsFormatted = `${hours < 10 ? "0" : ""}${hours}:${ - minutes < 10 ? "0" : "" - }${minutes}:${seconds < 10 ? "0" : ""}${parseInt(seconds, 10)}`; - return hmsFormatted; -} - /** * Converts a number of milliseconds to a number of days */ diff --git a/src/main/webapp/ui/src/stores/definitions/__tests__/Units/msToHMS.test.js b/src/main/webapp/ui/src/stores/definitions/__tests__/Units/msToHMS.test.js deleted file mode 100644 index bf9e5aba9..000000000 --- a/src/main/webapp/ui/src/stores/definitions/__tests__/Units/msToHMS.test.js +++ /dev/null @@ -1,14 +0,0 @@ -//@flow -/* eslint-env jest */ -import fc from "fast-check"; -import { msToHMS } from "../../Units"; - -describe("msToHMS", () => { - test("Input of less than a day should output format of a series of 2 digits", () => { - fc.assert( - fc.property(fc.nat(24 * 60 * 60 * 1000), (ms) => { - expect(/\d\d:\d\d:\d\d/.test(msToHMS(ms))).toBe(true); - }) - ); - }); -});