diff --git a/package-lock.json b/package-lock.json index a7d20bb6..a941b771 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "station-data-portal", "version": "1.5.0", "dependencies": { + "@tanstack/react-query": "^5.20.2", "axios": "^0.27.2", "bootstrap": "^5.1.3", "chroma-js": "^2.4.2", @@ -45,6 +46,7 @@ "zustand": "^4.4.7" }, "devDependencies": { + "@tanstack/react-query-devtools": "^5.20.2", "husky": "^8.0.3", "jest-each": "^28.1.1", "lint-staged": "^15.2.0", @@ -3726,6 +3728,57 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@tanstack/query-core": { + "version": "5.20.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.20.2.tgz", + "integrity": "sha512-sAILwNiyA1I52e6imOsmNDUA/PuOayOzqz5jcLiIB5wBXqVk+HIiriWouPcAkjS8RqARfHUehuoPwcZ7Uzh0GQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.20.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.20.2.tgz", + "integrity": "sha512-BZfSjhk/NGPbqte5E3Vc1Zbj28uWt///4I0DgzAdWrOtMVvdl0WlUXK23K2daLsbcyfoDR4jRI4f2Z5z/mMzuw==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.20.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.20.2.tgz", + "integrity": "sha512-949myvMY77cPqwb71m3wRG2ypgwPijshO5kN9w0CDKWrFC0X8Wh1mwSqst88kIr58tWlWNsGy3U40AK23RgYQA==", + "dependencies": { + "@tanstack/query-core": "5.20.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.20.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.20.2.tgz", + "integrity": "sha512-HFthuTbgs06hc/9U+5cq+7mSoa0JzU00eH6zWwpIJTSk0WL3RZJ3TyEMN7TYrHFFAatiE+4dySmTwPwI+nSU+g==", + "dev": true, + "dependencies": { + "@tanstack/query-devtools": "5.20.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.20.2", + "react": "^18.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.16.0.tgz", diff --git a/package.json b/package.json index 0b24c125..4607996e 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.5.0", "private": true, "dependencies": { + "@tanstack/react-query": "^5.20.2", "axios": "^0.27.2", "bootstrap": "^5.1.3", "chroma-js": "^2.4.2", @@ -40,6 +41,7 @@ "zustand": "^4.4.7" }, "devDependencies": { + "@tanstack/react-query-devtools": "^5.20.2", "husky": "^8.0.3", "jest-each": "^28.1.1", "lint-staged": "^15.2.0", diff --git a/src/api/metadata.js b/src/api/metadata.js deleted file mode 100644 index 320d7980..00000000 --- a/src/api/metadata.js +++ /dev/null @@ -1,129 +0,0 @@ -import axios from "axios"; -import urljoin from "url-join"; -import isString from "lodash/fp/isString"; -import tap from "lodash/fp/tap"; -import { mapDeep } from "../utils/fp/fp"; -import { filterExpressionsParser, filterPredicate } from "./filtering"; -import filter from "lodash/fp/filter"; - -// TODO: Think about replacing parameter `appConfig` in data services with -// a module-local variable and an exported setter, which would be called -// from app initialization. Neither is a nice solution. - -// Regex for ISO 8601 date strings; allows YYYY-MM-DD with optional T spec. -// Now you've got two problems :) -const ISO_8601 = /\d{4}-\d{2}-\d{2}(Td{2}:\d{2}:\d{2})?/; - -function transformIso8601Date(value) { - // If `value` is a string that matches the ISO 8601 date format, - // transform it to a JS Date. - // Otherwise return it unmolested. - const isDateString = isString(value) && ISO_8601.test(value); - return isDateString ? new Date(Date.parse(value)) : value; -} - -export function getNetworks({ config }) { - const parsedNetworkFilterExpressions = filterExpressionsParser( - config.networkFilters, - ); - const filterNetworks = filter( - filterPredicate(parsedNetworkFilterExpressions), - ); - - return axios.get(urljoin(config.sdsUrl, "networks"), { - params: { - provinces: config.stationsQpProvinces, - }, - transformResponse: axios.defaults.transformResponse.concat(filterNetworks), - }); -} - -export function getVariables({ config }) { - return axios.get(urljoin(config.sdsUrl, "variables"), { - params: { - provinces: config.stationsQpProvinces, - }, - }); -} - -export const getFrequencies = ({ config }) => - axios.get(urljoin(config.sdsUrl, "frequencies"), { - params: { - provinces: config.stationsQpProvinces, - }, - }); - -export const getHistories = ({ config }) => - axios.get(urljoin(config.sdsUrl, "histories")); - -export function getStations({ config, getParams, axiosConfig }) { - const parsedStationFilterExpressions = filterExpressionsParser( - config.stationFilters, - ); - const filterStations = filter( - filterPredicate(parsedStationFilterExpressions), - ); - - return axios.get(urljoin(config.sdsUrl, "stations"), { - params: { - offset: config.stationOffset, - limit: config.stationLimit, - stride: config.stationStride, - provinces: config.stationsQpProvinces, - ...getParams, - }, - transformResponse: axios.defaults.transformResponse.concat( - tap((x) => console.log("raw station count", x.length)), - filterStations, - mapDeep(transformIso8601Date), - ), - ...axiosConfig, - }); -} - -export const getStationById = ({ config, stationId, axiosConfig }) => - axios.get(urljoin(config.sdsUrl, "stations", stationId), { - transformResponse: axios.defaults.transformResponse.concat( - mapDeep(transformIso8601Date), - ), - ...axiosConfig, - }); - -export const getStationVariables = ({ config, stationId }) => - axios.get( - urljoin(config.sdsUrl, "stations", stationId.toString(), "variables"), - ); - -export const getStationVariablesObservations = - ({ config, stationId, startDate, endDate }) => - (variableId) => - axios.get( - urljoin( - config.sdsUrl, - "stations", - stationId.toString(), - "variables", - variableId.toString(), - "observations", - ), - { - params: { - start_date: startDate.toISOString(), - end_date: endDate.toISOString(), - }, - }, - ); - -export function getObservationCounts({ - appConfig: config, - getParams, - axiosConfig, -}) { - return axios.get(urljoin(config.sdsUrl, "observations", "counts"), { - params: { - provinces: config.stationsQpProvinces, - ...getParams, - }, - ...axiosConfig, - }); -} diff --git a/src/components/controls/StationFilters/StationFilters.js b/src/components/controls/StationFilters/StationFilters.js index 0d34a6fc..8fbe260f 100644 --- a/src/components/controls/StationFilters/StationFilters.js +++ b/src/components/controls/StationFilters/StationFilters.js @@ -34,6 +34,9 @@ import DateSelector from "../../selectors/DateSelector"; import OnlyWithClimatologyControl from "../../controls/OnlyWithClimatologyControl"; import { commonSelectorStyles } from "../../selectors/styles"; import { usePairedImmerByKey } from "../../../hooks"; +import { useNetworks } from "../../../state/query-hooks/use-networks"; +import { useVariables } from "../../../state/query-hooks/use-variables"; +import { useFrequencies } from "../../../state/query-hooks/use-frequencies"; export const useStationFiltering = () => { const { normal, transitional, isPending, setState } = usePairedImmerByKey({ @@ -58,13 +61,12 @@ export const useStationFiltering = () => { function StationFilters({ state, setState, - metadata: { - networks: allNetworks, - variables: allVariables, - frequencies: allFrequencies, - }, rowClasses = { className: "mb-3" }, }) { + const { data: allNetworks } = useNetworks(); + const { data: allVariables } = useVariables(); + const { data: allFrequencies } = useFrequencies(); + return ( diff --git a/src/components/daterange/index.js b/src/components/daterange/index.js index 1a0cc9b5..612ae6cf 100644 --- a/src/components/daterange/index.js +++ b/src/components/daterange/index.js @@ -62,7 +62,7 @@ const getFormattedIntervals = ( const formattedBlockedDates = blockedDates .sort((a, b) => a.start - b.start) .map((interval, index) => { - let { start, end, type } = interval; + let { start, end, type, color } = interval; if (isBefore(start, startTime)) start = startTime; if (isAfter(end, endTime)) end = endTime; @@ -70,7 +70,7 @@ const getFormattedIntervals = ( const source = getConfig(start); const target = getConfig(end); - return { id: `${classPrefix}-${index}`, source, target, type }; + return { id: `${classPrefix}-${index}`, source, target, type, color }; }); console.log("### formattedBlockedDates", formattedBlockedDates); @@ -258,18 +258,21 @@ class DateRange extends React.Component { {({ getTrackProps }) => ( <> - {dataIntervals.map(({ id, source, target, type }, index) => ( - - ))} + {dataIntervals.map( + ({ id, source, target, type, color }, index) => ( + + ), + )} )} diff --git a/src/components/daterange/styles/index.scss b/src/components/daterange/styles/index.scss index 93cf3fd1..01de5fde 100644 --- a/src/components/daterange/styles/index.scss +++ b/src/components/daterange/styles/index.scss @@ -60,11 +60,9 @@ $react-time-range--track--disabled: repeating-linear-gradient( z-index: 2; &__observation { @extend .react_time_range__data_track; - background-color: red; } &__climatology { @extend .react_time_range__data_track; - background-color: green; } } diff --git a/src/components/daterange/sub/DataTrack.js b/src/components/daterange/sub/DataTrack.js index 418c69c0..7039d0df 100644 --- a/src/components/daterange/sub/DataTrack.js +++ b/src/components/daterange/sub/DataTrack.js @@ -18,23 +18,33 @@ const baseHeight = 50; * @param {Object} params.index the index of this data track * @returns {Object} basicStyle **/ -const getTrackStyle = ({ source, target, count, index }) => { +const getTrackStyle = ({ source, target, count, index, color }) => { const height = baseHeight / count / 2; const topPosition = -22 + (baseHeight / count) * index + height / 2; + console.log("### getTrackStyle", source, target, count, index, color); const basicStyle = { top: `${topPosition}px`, height: `${height}px`, left: `${source.percent}%`, width: `calc(${target.percent - source.percent}% - 1px)`, + backgroundColor: color, }; return basicStyle; }; -const DataTrack = ({ source, target, getTrackProps, count, index, type }) => ( +const DataTrack = ({ + source, + target, + getTrackProps, + count, + index, + type, + color, +}) => (
); diff --git a/src/components/info/Disclaimer/Disclaimer.js b/src/components/info/Disclaimer/Disclaimer.js index b51acf45..17ca4cc1 100644 --- a/src/components/info/Disclaimer/Disclaimer.js +++ b/src/components/info/Disclaimer/Disclaimer.js @@ -2,9 +2,10 @@ import React, { useState } from "react"; import { Button, Modal } from "react-bootstrap"; import "./Disclaimer.css"; import { useStore } from "../../../state/state-store"; +import { useConfig } from "../../../state/query-hooks/use-config"; function Disclaimer() { - const config = useStore((state) => state.config); + const { data: config } = useConfig(); const [acknowledged, setAcknowledged] = useState(!config.disclaimer.enabled); const acknowledge = () => setAcknowledged(true); diff --git a/src/components/info/NetworksMetadata/NetworksMetadata.js b/src/components/info/NetworksMetadata/NetworksMetadata.js index ced26a8a..f63122ca 100644 --- a/src/components/info/NetworksMetadata/NetworksMetadata.js +++ b/src/components/info/NetworksMetadata/NetworksMetadata.js @@ -9,12 +9,14 @@ import { useSortBy, useTable } from "react-table"; import logger from "../../../logger"; import chroma from "chroma-js"; import "./NetworksMetadata.css"; -import { useStore } from "../../../state/state-store"; +import { useConfig } from "../../../state/query-hooks/use-config"; +import { useNetworks } from "../../../state/query-hooks/use-networks"; logger.configure({ active: true }); -function NetworksMetadata({ networks }) { - const config = useStore((state) => state.config); +function NetworksMetadata() { + const { data: config } = useConfig(); + const { data, isLoading, isError } = useNetworks(); const columns = React.useMemo( () => [ @@ -61,84 +63,85 @@ function NetworksMetadata({ networks }) { accessor: "station_count", }, ], - [config.defaultNetworkColor], + [config?.defaultNetworkColor], ); - const data = React.useMemo(() => networks ?? [], [networks]); + // TODO: do I need to add back the networks use memo? - const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = - useTable( - { - columns, - data, - initialState: { - sortBy: [{ id: "Short Name" }], - }, - }, - useSortBy, - ); + // const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = + // useTable( + // { + // columns, + // data: (isLoading ? [] : data), + // initialState: { + // sortBy: [{ id: "Short Name" }], + // }, + // }, + // useSortBy, + // ); - if (networks === null) { + if (isLoading) { return "Loading..."; } - return ( -
- - - { - // Header rows - headerGroups.map((headerGroup) => ( - - { - // Header cells - headerGroup.headers.map((column) => { - const sortClass = column.isSorted - ? column.isSortedDesc - ? "sorted-desc" - : "sorted-asc" - : ""; - return ( - - ); - }) - } - - )) - } - + return
Networks Table
; + // return ( + //
+ //
- {column.render("Header")} -
+ // + // { + // // Header rows + // headerGroups.map((headerGroup) => ( + // + // { + // // Header cells + // headerGroup.headers.map((column) => { + // const sortClass = column.isSorted + // ? column.isSortedDesc + // ? "sorted-desc" + // : "sorted-asc" + // : ""; + // return ( + // + // ); + // }) + // } + // + // )) + // } + // - - { - // Body rows - rows.map((row) => { - // Prepare the row for display - prepareRow(row); - return ( - - { - // Body cells - row.cells.map((cell) => { - return ( - - ); - }) - } - - ); - }) - } - -
+ // {column.render("Header")} + //
{cell.render("Cell")}
-
- ); + // + // { + // // Body rows + // rows.map((row) => { + // // Prepare the row for display + // prepareRow(row); + // return ( + // + // { + // // Body cells + // row.cells.map((cell) => { + // return ( + // {cell.render("Cell")} + // ); + // }) + // } + // + // ); + // }) + // } + // + // + //
+ // ); } NetworksMetadata.propTypes = { diff --git a/src/components/info/ObservationCounts/ObservationCounts.js b/src/components/info/ObservationCounts/ObservationCounts.js index 80c17638..c45b73c7 100644 --- a/src/components/info/ObservationCounts/ObservationCounts.js +++ b/src/components/info/ObservationCounts/ObservationCounts.js @@ -2,13 +2,12 @@ import PropTypes from "prop-types"; import React, { useEffect, useState, useMemo } from "react"; import { Table } from "react-bootstrap"; import { reduce } from "lodash/fp"; -import { getObservationCounts } from "../../../api/metadata"; import InfoPopup from "../../util/InfoPopup"; import logger from "../../../logger"; import { getTimer } from "../../../utils/timing"; -import { useStore } from "../../../state/state-store"; - import "./ObservationCounts.css"; +import { useConfig } from "../../../state/query-hooks/use-config"; +import { useObservationCounts } from "../../../state/query-hooks/use-observation-counts"; logger.configure({ active: true }); const timer = getTimer("Observation count timing"); @@ -32,24 +31,20 @@ function ObservationCounts({ clipToDate, stations, }) { - const appConfig = useStore((state) => state.config); - const [countData, setCountData] = useState(null); - - useEffect(() => { - setCountData(null); - getObservationCounts({ - appConfig, - getParams: { - start_date: clipToDate ? dateToStrForQuery(startDate) : undefined, - end_date: clipToDate ? dateToStrForQuery(endDate) : undefined, - }, - }).then((response) => setCountData(response.data)); - }, [appConfig, clipToDate, startDate, endDate]); + const { data: config } = useConfig(); + const { + data: countData, + isLoading, + isError, + } = useObservationCounts( + dateToStrForQuery(startDate), + dateToStrForQuery(endDate), + ); const loadingMessage = "Loading ..."; const countTotals = useMemo(() => { - if (countData === null) { + if (isLoading || countData === null) { return { observations: null, climatologies: null }; } const monthlyObservations = totalCounts( diff --git a/src/components/info/StationData/StationData.js b/src/components/info/StationData/StationData.js index 9070391f..2cfc04f1 100644 --- a/src/components/info/StationData/StationData.js +++ b/src/components/info/StationData/StationData.js @@ -3,7 +3,6 @@ import React, { useState } from "react"; import { Button, ButtonToolbar, Col, Row } from "react-bootstrap"; import capitalize from "lodash/fp/capitalize"; import map from "lodash/fp/map"; -import { useStore } from "../../../state/state-store"; import FileFormatSelector from "../../selectors/FileFormatSelector"; import ClipToDateControl from "../../controls/ClipToDateControl"; import ObservationCounts from "../../info/ObservationCounts"; @@ -12,6 +11,7 @@ import InfoPopup from "../../util/InfoPopup"; import logger from "../../../logger"; import "./StationData.css"; +import { useConfig } from "../../../state/query-hooks/use-config"; logger.configure({ active: true }); @@ -22,7 +22,7 @@ function StationData({ dataDownloadFilename, rowClasses, }) { - const config = useStore((state) => state.config); + const { data: config } = useConfig(); const [fileFormat, setFileFormat] = useState(); const [clipToDate, setClipToDate] = useState(false); const toggleClipToDate = () => setClipToDate(!clipToDate); diff --git a/src/components/info/StationMetadata/StationMetadata.js b/src/components/info/StationMetadata/StationMetadata.js index fd71e0a5..b41e3625 100644 --- a/src/components/info/StationMetadata/StationMetadata.js +++ b/src/components/info/StationMetadata/StationMetadata.js @@ -20,13 +20,14 @@ import { smtColumnInfo, smtData } from "./column-definitions"; import logger from "../../../logger"; import "./StationMetadata.css"; +import { useNetworks } from "../../../state/query-hooks/use-networks"; +import { useVariables } from "../../../state/query-hooks/use-variables"; logger.configure({ active: true }); -function StationMetadata({ - stations, - metadata: { networks: allNetworks, variables: allVariables }, -}) { +function StationMetadata({ stations }) { + const { data: allNetworks } = useNetworks(); + const { data: allVariables } = useVariables(); const [helpVisible, setHelpVisible] = useState(false); const [compact, setCompact] = useState(false); const [isPending, startTransition] = useTransition(); diff --git a/src/components/main/App/App.js b/src/components/main/App/App.js index fcea8469..ddd843af 100644 --- a/src/components/main/App/App.js +++ b/src/components/main/App/App.js @@ -1,36 +1,22 @@ import React, { useEffect } from "react"; import { Container } from "react-bootstrap"; - import Disclaimer from "../../info/Disclaimer"; import Header from "../Header/Header"; -import Body from "../Body"; -import useInitializeApp from "./app-initialization"; -import { useStore } from "../../../state/state-store"; +import useInitializeApp from "./use-initialize-app"; import { Outlet } from "react-router-dom"; import "./App.css"; +import { useConfig } from "../../../state/query-hooks/use-config"; export default function App() { - // must be invoked before any other items dependent on context. - const initialize = useStore((state) => state.initialize); - const isConfigLoaded = useStore((state) => state.isConfigLoaded); - const configErrorMessage = useStore((state) => state.configError); - const config = useStore((state) => state.config); - const loadMetadata = useStore((state) => state.loadMetadata); - - useEffect(() => { - if (!isConfigLoaded() && configErrorMessage === null) { - initialize(); - } - }); - - useInitializeApp(config); + const { isLoading, isError } = useConfig(); + useInitializeApp(); - if (configErrorMessage !== null) { + if (isError) { return
{configErrorMessage}
; } - if (config === null) { + if (isLoading) { return
Loading configuration...
; } diff --git a/src/components/main/App/app-initialization.js b/src/components/main/App/use-initialize-app.js similarity index 74% rename from src/components/main/App/app-initialization.js rename to src/components/main/App/use-initialize-app.js index c6a97d6a..d7750a15 100644 --- a/src/components/main/App/app-initialization.js +++ b/src/components/main/App/use-initialize-app.js @@ -3,8 +3,10 @@ import { useEffect } from "react"; import L from "leaflet"; import { setLethargicMapScrolling } from "../../../utils/leaflet-extensions"; import { setTimingEnabled } from "../../../utils/timing"; +import { useConfig } from "../../../state/query-hooks/use-config"; function initializeApp(config) { + console.log("### initializeApp", config); if (config === null) { return; } @@ -38,8 +40,16 @@ function initializeApp(config) { setTimingEnabled(config.timingEnabled); } -export default function useInitializeApp(config) { +/** + * This hook loads the config and initializes the app. Primarily setting up Map, Scrolling behaviour and debug timing. + * + * @returns {void} + */ +export default function useInitializeApp() { + const { data } = useConfig(); useEffect(() => { - initializeApp(config); - }, [config]); + if (data) { + initializeApp(data); + } + }, [data]); } diff --git a/src/components/main/Body/Body.js b/src/components/main/Body/Body.js index aae93721..48b1eec3 100644 --- a/src/components/main/Body/Body.js +++ b/src/components/main/Body/Body.js @@ -1,10 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; -import { useImmerByKey } from "../../../hooks"; -import { Button, Card, Col, Row, Tab, Tabs } from "react-bootstrap"; +import { Col, Row, Tab, Tabs } from "react-bootstrap"; import Select from "react-select"; -import css from "../common.module.css"; - import logger from "../../../logger"; import { dataDownloadFilename, @@ -28,48 +25,41 @@ import StationFilters, { import baseMaps from "../../maps/baseMaps"; import { useStore } from "../../../state/state-store"; import { useShallow } from "zustand/react/shallow"; +import { useConfig } from "../../../state/query-hooks/use-config"; +import { useStations } from "../../../state/query-hooks/use-stations"; +import { useVariables } from "../../../state/query-hooks/use-variables"; +import { useFrequencies } from "../../../state/query-hooks/use-frequencies"; + +import css from "../common.module.css"; logger.configure({ active: true }); function Body() { - const config = useStore((state) => state.config); - - // metadata are the data items that can be watched for changes and - // should probably cause a re-render. - const metadata = useStore( - useShallow((state) => ({ - networks: state.networks, - stations: state.stations, - variables: state.variables, - frequencies: state.frequencies, - stationsLimit: state.stationsLimit, - })), - ); + const { data: config } = useConfig(); + const { + data: stations, + isLoading: isStationsLoading, + isError: isStationsError, + } = useStations(); + const { + data: variables, + isLoading: isVariablesLoading, + isError: isVariablesError, + } = useVariables(); + const { + data: frequencies, + isLoading: isFrequenciesLoading, + isError: isFrequenciesError, + } = useFrequencies(); // actions should be fixed functions on the store, so they shouldn't really change const actions = useStore( useShallow((state) => ({ setStationsLimit: state.setStationsLimit, reloadStations: state.reloadStations, - loadStations: state.loadStations, - loadMetadata: state.loadMetadata, - isConfigLoaded: state.isConfigLoaded, })), ); - // load data once on initial render after config is loaded - useEffect(() => { - if (actions.isConfigLoaded()) { - actions.loadMetadata(); - } - }, [config]); - - useEffect(() => { - if (config) { - actions.loadStations(); - } - }, [config]); - // Station filtering state and setters const { normal: filterValuesNormal, @@ -88,9 +78,9 @@ function Body() { () => stationFilter({ filterValues: filterValuesTransitional, - metadata, + metadata: { stations, variables }, }), - [filterValuesTransitional, metadata], + [filterValuesTransitional, stations, variables], ); const selectedStations = useMemo( @@ -109,9 +99,8 @@ function Body() { , @@ -136,7 +125,7 @@ function Body() {

@@ -147,7 +136,6 @@ function Body() { @@ -155,20 +143,17 @@ function Body() { - + @@ -199,7 +184,7 @@ function Body() { - + , ]} diff --git a/src/components/main/Header/Header.js b/src/components/main/Header/Header.js index 421824db..23199461 100644 --- a/src/components/main/Header/Header.js +++ b/src/components/main/Header/Header.js @@ -1,10 +1,10 @@ import React from "react"; import { Row, Col } from "react-bootstrap"; -import { useStore } from "../../../state/state-store"; import "./Header.css"; +import { useConfig } from "../../../state/query-hooks/use-config"; function Header() { - const config = useStore((state) => state.config); + const { data: config } = useConfig(); return ( diff --git a/src/components/maps/StationMap/StationMap.js b/src/components/maps/StationMap/StationMap.js index a26a5e17..da41c6fb 100644 --- a/src/components/maps/StationMap/StationMap.js +++ b/src/components/maps/StationMap/StationMap.js @@ -54,16 +54,16 @@ import { MapSpinner } from "pcic-react-leaflet-components"; import { useImmer } from "use-immer"; import { useStore } from "../../../state/state-store"; import { StationRefresh } from "../StationRefresh/StationRefresh"; +import { useConfig } from "../../../state/query-hooks/use-config"; logger.configure({ active: true }); const smtimer = getTimer("StationMarker timing"); smtimer.log(); function StationMap({ + stations, BaseMap, initialViewport, - stations, - metadata, onSetArea = () => {}, userShapeStyle = { color: "#f49853", @@ -76,7 +76,7 @@ function StationMap({ // should be true if and only if slow updates to the map are pending // due to an external update. }) { - const config = useStore((state) => state.config); + const { data: config } = useConfig(); const userShapeLayerRef = useRef(); // TODO: Remove @@ -121,7 +121,6 @@ function StationMap({ () => ( @@ -187,8 +186,6 @@ StationMap = React.memo(StationMap); StationMap.propTypes = { BaseMap: PropTypes.func.isRequired, initialViewport: PropTypes.object.isRequired, - stations: PropTypes.array.isRequired, - metadata: PropTypes.object, onSetArea: PropTypes.func, }; diff --git a/src/components/maps/StationMarkers/StationMarkers.js b/src/components/maps/StationMarkers/StationMarkers.js index ef053b18..ec6e27f7 100644 --- a/src/components/maps/StationMarkers/StationMarkers.js +++ b/src/components/maps/StationMarkers/StationMarkers.js @@ -16,6 +16,8 @@ import { } from "../../../utils/station-info"; import chroma from "chroma-js"; import { getTimer } from "../../../utils/timing"; +import { useStations } from "../../../state/query-hooks/use-stations"; +import { useNetworks } from "../../../state/query-hooks/use-networks"; logger.configure({ active: true }); const timer = getTimer("StationMarkers timing"); @@ -27,14 +29,14 @@ const timer = getTimer("StationMarkers timing"); // triggers popup. Creates the popup (once; effectively memoized). // `popup`: Lazily created popup to be rendered inside marker. Value `null` // until `addPopup` called; value is the popup thereafter. -const useLazyPopup = ({ station, metadata }) => { +const useLazyPopup = ({ station }) => { const markerRef = useRef(); const [popup, setPopup] = useState(null); // Callback: create popup if not already created. const addPopup = () => { if (popup === null) { - setPopup(); + setPopup(); } }; @@ -61,9 +63,8 @@ function LocationMarker({ location, // One location of the station (there may be several) color, // Station colour; overrides default color in markerOptions markerOptions = defaultMarkerOptions, - metadata, }) { - const { markerRef, popup, addPopup } = useLazyPopup({ station, metadata }); + const { markerRef, popup, addPopup } = useLazyPopup({ station }); return ( - + {popup} ); @@ -83,7 +84,6 @@ LocationMarker.propTypes = { station: PropTypes.object.isRequired, location: PropTypes.object.isRequired, color: PropTypes.string, - metadata: PropTypes.object.isRequired, markerOptions: PropTypes.object, }; @@ -92,9 +92,8 @@ function MultiLocationMarker({ locations, // Unique locations for station. color, // Station colour; applied to all location markers polygonOptions, // Multi-location marker is a polygon; this is its format - metadata, }) { - const { markerRef, popup, addPopup } = useLazyPopup({ station, metadata }); + const { markerRef, popup, addPopup } = useLazyPopup({ station }); if (locations.length <= 1) { return null; @@ -107,7 +106,7 @@ function MultiLocationMarker({ positions={locations} onClick={addPopup} > - + {popup} ); @@ -116,20 +115,19 @@ MultiLocationMarker.propTypes = { station: PropTypes.object.isRequired, locations: PropTypes.array.isRequired, color: PropTypes.string.isRequired, - metadata: PropTypes.object.isRequired, polygonOptions: PropTypes.object, }; function OneStationMarkers({ station, - metadata, markerOptions = defaultMarkerOptions, // TODO: Improve or remove polygonOptions = { color: "green", }, }) { - const network = stationNetwork(metadata.networks, station); + const { data: networks } = useNetworks(); + const network = stationNetwork(networks, station); const locationColor = network?.color; const polygonColor = chroma(network?.color ?? polygonOptions.color) .alpha(0.3) @@ -148,7 +146,6 @@ function OneStationMarkers({ station={station} location={location} color={locationColor} - metadata={metadata} markerOptions={markerOptions} key={location.id} /> @@ -160,7 +157,6 @@ function OneStationMarkers({ locations={uniqLatLngs} color={polygonColor} polygonOptions={polygonOptions} - metadata={metadata} /> ); @@ -168,20 +164,15 @@ function OneStationMarkers({ OneStationMarkers = timer.timeThis("OneStationMarkers")(OneStationMarkers); OneStationMarkers.propTypes = { station: PropTypes.object.isRequired, - metadata: PropTypes.object.isRequired, markerOptions: PropTypes.object, polygonOptions: PropTypes.object, }; function ManyStationMarkers({ - stations, - metadata, markerOptions = defaultMarkerOptions, mapEvents = {}, }) { - // Add map events passed in from outside. The callbacks are called - // with the map as the first argument. - // TODO: This might be worth making into a custom hook. + const { data: stations } = useStations(); const leafletMap = useMap(); const mapEventsWithMap = mapValues( (eventCallback) => @@ -195,7 +186,6 @@ function ManyStationMarkers({ ), @@ -205,7 +195,6 @@ function ManyStationMarkers({ // ManyStationMarkers = React.memo(ManyStationMarkers); ManyStationMarkers.propTypes = { stations: PropTypes.arrayOf(PropTypes.object).isRequired, - metadata: PropTypes.object.isRequired, markerOptions: PropTypes.object, mapEvents: PropTypes.object, }; diff --git a/src/components/maps/StationPopup/StationPopup.js b/src/components/maps/StationPopup/StationPopup.js index 3c91f394..37902461 100644 --- a/src/components/maps/StationPopup/StationPopup.js +++ b/src/components/maps/StationPopup/StationPopup.js @@ -21,16 +21,20 @@ import { uniqStationObsPeriods, uniqStationVariableNames, } from "../../../utils/station-info"; -import { useStore } from "../../../state/state-store"; +import { useConfig } from "../../../state/query-hooks/use-config"; +import { useNetworks } from "../../../state/query-hooks/use-networks"; +import { useVariables } from "../../../state/query-hooks/use-variables"; logger.configure({ active: true }); const formatDate = (d) => (d ? d.toISOString().substr(0, 10) : "unknown"); -function StationPopup({ station, metadata }) { - const config = useStore((state) => state.config); +function StationPopup({ station }) { + const { data: config } = useConfig(); + const { data: networks } = useNetworks(); + const { data: variables } = useVariables(); - const network = stationNetwork(metadata.networks, station); + const network = stationNetwork(networks, station); const networkColor = chroma(network.color ?? config.defaultNetworkColor) .alpha(0.5) .css(); @@ -87,7 +91,7 @@ function StationPopup({ station, metadata }) { ); - const usvns = uniqStationVariableNames(metadata.variables, station); + const usvns = uniqStationVariableNames(variables, station); const variableNames = usvns.length === 0 ? ( No observations @@ -157,7 +161,6 @@ function StationPopup({ station, metadata }) { StationPopup.propTypes = { station: PropTypes.object.isRequired, - metadata: PropTypes.object.isRequired, }; export default StationPopup; diff --git a/src/components/maps/StationTooltip/StationTooltip.js b/src/components/maps/StationTooltip/StationTooltip.js index 3c46f4ae..0a20dff4 100644 --- a/src/components/maps/StationTooltip/StationTooltip.js +++ b/src/components/maps/StationTooltip/StationTooltip.js @@ -4,10 +4,12 @@ import { Tooltip } from "react-leaflet"; import { stationNetwork, uniqStationNames } from "../../../utils/station-info"; import flow from "lodash/fp/flow"; import join from "lodash/fp/join"; +import { useNetworks } from "../../../state/query-hooks/use-networks"; import "./StationTooltip.css"; -function StationTooltip({ station, metadata }) { - const network = stationNetwork(metadata.networks, station); +function StationTooltip({ station }) { + const { data: networks } = useNetworks(); + const network = stationNetwork(networks, station); const stationNames = flow(uniqStationNames, join(", "))(station); return ( @@ -19,7 +21,6 @@ function StationTooltip({ station, metadata }) { StationTooltip.propTypes = { station: PropTypes.object.isRequired, - metadata: PropTypes.object.isRequired, }; export default StationTooltip; diff --git a/src/components/preview/StationPreview/GraphsBlock.js b/src/components/preview/StationPreview/GraphsBlock.js index e0ae8f46..ae62e126 100644 --- a/src/components/preview/StationPreview/GraphsBlock.js +++ b/src/components/preview/StationPreview/GraphsBlock.js @@ -21,7 +21,7 @@ const GraphsBlock = () => { {map((variable) => { return ( - + {showLegend && ( diff --git a/src/components/preview/StationPreview/HeaderBlock.js b/src/components/preview/StationPreview/HeaderBlock.js index 0e1c2152..9aaccdd6 100644 --- a/src/components/preview/StationPreview/HeaderBlock.js +++ b/src/components/preview/StationPreview/HeaderBlock.js @@ -1,53 +1,65 @@ import React from "react"; import map from "lodash/fp/map"; -import { Accordion, Table } from "react-bootstrap"; +import { Accordion, Table, Row, Col, Spinner } from "react-bootstrap"; import { useStore } from "../../../state/state-store"; export const HeaderBlock = () => { - const { station } = useStore((state) => ({ - station: state.previewStation, + const { previewStation } = useStore((state) => ({ + previewStation: state.previewStation, })); - if (!station) { - return null; + if (!previewStation) { + return ( + + + + Loading... + + + + ); } return ( - - {map((history) => ( - - -

- {history.station_name}:{" "} - {history?.min_obs_time?.toISOString().split("T")[0]} to{" "} - {history?.max_obs_time?.toISOString().split("T")[0]} -

- - - - - - - - - - - - - - - - - - - - - -
Lat: {history.lat}
Long: {history.lon}
Elevation:{history.elevation}
Province: {history.province}
-
-
- ))(station.histories)} - + + + + {map((history) => ( + + +

+ {history.station_name}:{" "} + {history?.min_obs_time?.toISOString().split("T")[0]} to{" "} + {history?.max_obs_time?.toISOString().split("T")[0]} +

+
+ + + + + + + + + + + + + + + + + + + + +
Lat: {history.lat}
Long: {history.lon}
Elevation:{history.elevation}
Province: {history.province}
+
+
+ ))(previewStation.histories)} +
+ +
); }; diff --git a/src/components/preview/StationPreview/NavBlock.js b/src/components/preview/StationPreview/NavBlock.js index 998c7a1d..4e54c215 100644 --- a/src/components/preview/StationPreview/NavBlock.js +++ b/src/components/preview/StationPreview/NavBlock.js @@ -6,6 +6,8 @@ import { Form, Stack, Spinner, + Row, + Col, } from "react-bootstrap"; import { LinkContainer } from "react-router-bootstrap"; import { useShallow } from "zustand/react/shallow"; @@ -30,7 +32,19 @@ const NavBlock = () => { })); if (!data.previewStationVariables || !data.selectedEndDate) { - return ; + return ( + + + + + + Loading... + + + + + + ); } return ( diff --git a/src/components/preview/StationPreview/PreviewGraph.js b/src/components/preview/StationPreview/PreviewGraph.js index 16877828..a1f645ae 100644 --- a/src/components/preview/StationPreview/PreviewGraph.js +++ b/src/components/preview/StationPreview/PreviewGraph.js @@ -1,4 +1,5 @@ import React from "react"; +import { Spinner } from "react-bootstrap"; import map from "lodash/fp/map"; import { useShallow } from "zustand/react/shallow"; import { useStore } from "../../../state/state-store"; @@ -17,18 +18,25 @@ const getPlotData = (state, variableId) => { }; const PreviewGraph = ({ variableId }) => { - const { previewObservations, selectedStartDate, selectedEndDate } = useStore( - useShallow((state) => ({ - previewObservations: getPlotData(state, variableId), - selectedStartDate: state.selectedStartDate, - selectedEndDate: state.selectedEndDate, - })), - ); + const { previewObservations, selectedStartDate, selectedEndDate, config } = + useStore( + useShallow((state) => ({ + previewObservations: getPlotData(state, variableId), + selectedStartDate: state.selectedStartDate, + selectedEndDate: state.selectedEndDate, + showLegend: state.showLegend, + config: state.config, + })), + ); console.log("### previewObservations", previewObservations); if (previewObservations === null) { - return
Loading...
; + return ( + + Loading... + + ); } if ((previewObservations?.observations?.length ?? 0) === 0) { @@ -49,7 +57,7 @@ const PreviewGraph = ({ variableId }) => { y: map("value", previewObservations.observations), type: "scatter", mode: "lines", - marker: { color: "red" }, + marker: { color: config.plotColor }, }, ]} layout={{ @@ -62,7 +70,7 @@ const PreviewGraph = ({ variableId }) => { b: 50, //bottom }, autosize: true, - title: null, //plotData.variable.name, + title: showLegend ? null : previewObservations.variable.name, xaxis: { title: "Time", type: "date", diff --git a/src/components/preview/StationPreview/RangeBlock.js b/src/components/preview/StationPreview/RangeBlock.js index c8f62e51..7f58cece 100644 --- a/src/components/preview/StationPreview/RangeBlock.js +++ b/src/components/preview/StationPreview/RangeBlock.js @@ -14,6 +14,7 @@ const millisedondsPerDay = 86400000; const RangeBlock = ({}) => { const { + config, minStartDate, maxEndDate, selectedStartDate, @@ -22,6 +23,7 @@ const RangeBlock = ({}) => { setSelectedEndDate, previewStationVariables, } = useStore((state) => ({ + config: state.config, minStartDate: state.minStartDate, maxEndDate: state.maxEndDate, selectedStartDate: state.selectedStartDate, @@ -74,6 +76,7 @@ const RangeBlock = ({}) => { // return curr; // }; + console.log("### config", config); return ( { start: new Date(data.min_obs_time), end: new Date(data.max_obs_time), type: "observation", + color: config.plotColor, }))} //hideHandles={true} /> diff --git a/src/index.js b/src/index.js index f3f4140d..2e6f34b5 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,4 @@ -import React, { Suspense } from "react"; +import React from "react"; import { createBrowserRouter, createRoutesFromElements, @@ -7,6 +7,8 @@ import { } from "react-router-dom"; import { createRoot } from "react-dom/client"; import App from "./components/main/App"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import "bootstrap/dist/css/bootstrap.css"; import "react-datepicker/dist/react-datepicker.css"; @@ -16,18 +18,27 @@ import "./index.css"; import registerServiceWorker from "./registerServiceWorker"; -let baseName = "/"; -if (process.env.PUBLIC_URL) { - if (process.env.PUBLIC_URL.indexOf(".") >= 0) { - baseName = new URL(process.env.PUBLIC_URL).pathname; - } else { - // for development - baseName = process.env.PUBLIC_URL; +/** + * When deploying the app to a URL that doesn't sit on the domain root we need to let the + * app router know the location that it at so it knows what portion of the URL it is + * responsible for. + * + * @returns string The base URL of the app + */ +const getBaseName = () => { + if (process.env.PUBLIC_URL) { + if (process.env.PUBLIC_URL.indexOf(".") >= 0) { + return new URL(process.env.PUBLIC_URL).pathname; + } else { + // for development + return process.env.PUBLIC_URL; + } } -} + return "/"; +}; -console.log("### PUBLIC_URL", process.env.PUBLIC_URL); -console.log("### baseName", baseName); +// Create a client +const queryClient = new QueryClient(); // Code split our bundle along our primary routes using the "lazy" function. // https://reactrouter.com/en/main/route/lazy @@ -43,11 +54,16 @@ const router = createBrowserRouter( , ), { - basename: baseName, + basename: getBaseName(), }, ); const container = document.getElementById("root"); const root = createRoot(container); -root.render(); +root.render( + + + + , +); registerServiceWorker(); diff --git a/src/api/filtering.js b/src/state/query-hooks/filtering.js similarity index 100% rename from src/api/filtering.js rename to src/state/query-hooks/filtering.js diff --git a/src/api/filtering.test.js b/src/state/query-hooks/filtering.test.js similarity index 100% rename from src/api/filtering.test.js rename to src/state/query-hooks/filtering.test.js diff --git a/src/state/slice-config.js b/src/state/query-hooks/use-config.js similarity index 56% rename from src/state/slice-config.js rename to src/state/query-hooks/use-config.js index e9ac42a1..b37faf99 100644 --- a/src/state/slice-config.js +++ b/src/state/query-hooks/use-config.js @@ -1,3 +1,4 @@ +import { useQuery } from "@tanstack/react-query"; import yaml from "js-yaml"; import filter from "lodash/fp/filter"; import isUndefined from "lodash/fp/isUndefined"; @@ -83,58 +84,55 @@ const getZoomMarkerRadius = (zmrSpec) => { }; }; -const loadConfigAction = (set, get) => { - return async () => { - let config = {}; - try { - const response = await fetch(`${process.env.PUBLIC_URL}/config.yaml`); - const yamlConfig = await response.text(); - const fetchedConfig = yaml.load(yamlConfig); - config = { ...defaultConfig, ...fetchedConfig }; - } catch (error) { - set({ - configError: ( -
- Error loading or parsing config.yaml:
{error.toString()}
-
- ), - }); - throw error; - } +const fetchConfig = async () => { + let config = {}; + try { + const response = await fetch(`${process.env.PUBLIC_URL}/config.yaml`); + const yamlConfig = await response.text(); + const fetchedConfig = yaml.load(yamlConfig); + config = { ...defaultConfig, ...fetchedConfig }; + } catch (error) { + // set({ + // configError: ( + //
+ // Error loading or parsing config.yaml:
{error.toString()}
+ //
+ // ), + // }); + throw error; + } - try { - checkMissingKeys(config); - } catch (error) { - set({ - configError: error.toString(), - }); - throw error; - } + try { + checkMissingKeys(config); + } catch (error) { + // set({ + // configError: error.toString(), + // }); + throw error; + } - // Extend config with some env var values - config.appVersion = process.env.REACT_APP_APP_VERSION ?? "unknown"; + // Extend config with some env var values + config.appVersion = process.env.REACT_APP_APP_VERSION ?? "unknown"; - // Extend config with some computed goodies - // TODO: Store shouldn't know about data presentation - config.stationDebugFetchLimitsOptions = config.stationDebugFetchLimits.map( - (value) => ({ value, label: value.toString() }), - ); + // Extend config with some computed goodies + // TODO: Store shouldn't know about data presentation + config.stationDebugFetchLimitsOptions = config.stationDebugFetchLimits.map( + (value) => ({ value, label: value.toString() }), + ); - config.zoomToMarkerRadius = getZoomMarkerRadius( - config.zoomToMarkerRadiusSpec, - ); + // TODO: config shouldn't be responsible for this + config.zoomToMarkerRadius = getZoomMarkerRadius( + config.zoomToMarkerRadiusSpec, + ); - set({ config }); - }; + return config; }; -export const createConfigSlice = (set, get) => ({ - config: null, - configError: null, - isConfigLoaded: () => get().config !== null, - - // private actions - _loadConfig: loadConfigAction(set, get), -}); +const CONFIG_QUERY_KEY = ["config"]; -export default createConfigSlice; +export const useConfig = () => + useQuery({ + queryKey: CONFIG_QUERY_KEY, + queryFn: fetchConfig, + staleTime: Infinity, // config should rarely change while on the same version + }); diff --git a/src/state/query-hooks/use-frequencies.js b/src/state/query-hooks/use-frequencies.js new file mode 100644 index 00000000..a7f14d2d --- /dev/null +++ b/src/state/query-hooks/use-frequencies.js @@ -0,0 +1,28 @@ +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import urljoin from "url-join"; +import { useConfig } from "./use-config"; + +const getFrequencies = async ({ config }) => { + const { data } = await axios(urljoin(config.sdsUrl, "frequencies"), { + params: { + provinces: config.stationsQpProvinces, + }, + }); + + return data; +}; + +export const FREQUENCIES_QUERY_KEY = ["frequencies"]; + +export const useFrequencies = () => { + const { data: config } = useConfig(); + return useQuery({ + queryKey: FREQUENCIES_QUERY_KEY, + queryFn: () => getFrequencies({ config }), + enabled: !!config, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + }); +}; + +export default useFrequencies; diff --git a/src/state/query-hooks/use-histories.js b/src/state/query-hooks/use-histories.js new file mode 100644 index 00000000..c87b7f1e --- /dev/null +++ b/src/state/query-hooks/use-histories.js @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import urljoin from "url-join"; +import { useConfig } from "./use-config"; + +export const getHistories = async ({ config }) => { + const { data } = await axios.get(urljoin(config.sdsUrl, "histories")); + + return data; +}; + +export const HISTORIES_QUERY_KEY = ["histories"]; + +export const useHistories = () => { + const { data: config } = useConfig(); + return useQuery({ + queryKey: HISTORIES_QUERY_KEY, + queryFn: getHistories({ config }), + enabled: !!config, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + }); +}; diff --git a/src/state/query-hooks/use-networks.js b/src/state/query-hooks/use-networks.js new file mode 100644 index 00000000..d64af66b --- /dev/null +++ b/src/state/query-hooks/use-networks.js @@ -0,0 +1,41 @@ +import axios from "axios"; +import urljoin from "url-join"; +import { useQuery } from "@tanstack/react-query"; +import filter from "lodash/fp/filter"; +import { filterExpressionsParser, filterPredicate } from "./filtering"; +import { useConfig } from "./use-config"; + +/** + * + * @param {Object} + * @returns Promise + */ +export const getNetworks = async ({ config }) => { + const parsedNetworkFilterExpressions = filterExpressionsParser( + config.networkFilters, + ); + const filterNetworks = filter( + filterPredicate(parsedNetworkFilterExpressions), + ); + + const { data } = await axios(urljoin(config.sdsUrl, "networks"), { + params: { + provinces: config.stationsQpProvinces, + }, + transformResponse: axios.defaults.transformResponse.concat(filterNetworks), + }); + + return data; +}; + +export const NETWORKS_QUERY_KEY = "networks"; + +export const useNetworks = () => { + const { data: config } = useConfig(); + return useQuery({ + queryKey: [NETWORKS_QUERY_KEY], + queryFn: () => getNetworks({ config }), + enabled: !!config, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + }); +}; diff --git a/src/state/query-hooks/use-observation-counts.js b/src/state/query-hooks/use-observation-counts.js new file mode 100644 index 00000000..f1ced1c8 --- /dev/null +++ b/src/state/query-hooks/use-observation-counts.js @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import urljoin from "url-join"; +import { useConfig } from "./use-config"; + +export const OBSERVATION_COUNTS_QUERY_KEY = ["observation-counts"]; + +export const getObservationCounts = async ({ config, startDate, endDate }) => { + const { data } = await axios.get( + urljoin(config.sdsUrl, "observations", "counts"), + { + params: { + provinces: config.stationsQpProvinces, + start_date: startDate?.toISOString(), + end_date: endDate?.toISOString(), + }, + }, + ); + + return data; +}; + +export const useObservationCounts = (startDate, endDate) => { + const { data: config } = useConfig(); + return useQuery({ + queryKey: OBSERVATION_COUNTS_QUERY_KEY, + queryFn: () => getObservationCounts({ config, startDate, endDate }), + enabled: !!config, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + }); +}; diff --git a/src/state/query-hooks/use-station-variable-observations.js b/src/state/query-hooks/use-station-variable-observations.js new file mode 100644 index 00000000..be761016 --- /dev/null +++ b/src/state/query-hooks/use-station-variable-observations.js @@ -0,0 +1,62 @@ +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import urljoin from "url-join"; +import { useConfig } from "./use-config"; + +export const STATION_VARIABLE_OBSERVATIONS_QUERY_KEY = + "station-variable-observations"; + +export const getStationVariablesObservations = async ({ + config, + stationId, + variableId, + startDate, + endDate, +}) => { + const { data } = await axios.get( + urljoin( + config.sdsUrl, + "stations", + stationId.toString(), + "variables", + variableId.toString(), + "observations", + ), + { + params: { + start_date: startDate.toISOString(), + end_date: endDate.toISOString(), + }, + }, + ); + return data; +}; + +export const useStationVariableObservations = ( + stationId, + variableId, + startDate, + endDate, +) => { + const { data: config } = useConfig(); + + return useQuery({ + queryKey: [ + STATION_VARIABLE_OBSERVATIONS_QUERY_KEY, + stationId, + variableId, + startDate, + endDate, + ], + queryFn: () => + getStationVariablesObservations({ + config, + stationId, + variableId, + startDate, + endDate, + }), + enabled: !!config, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + }); +}; diff --git a/src/state/query-hooks/use-station-variables.js b/src/state/query-hooks/use-station-variables.js new file mode 100644 index 00000000..63af368c --- /dev/null +++ b/src/state/query-hooks/use-station-variables.js @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import urljoin from "url-join"; +import { useConfig } from "./use-config"; + +export const STATION_VARIABLES_QUERY_KEY = "station-variables"; + +export const getStationVariables = async ({ config, stationId }) => { + const { data } = await axios.get( + urljoin(config.sdsUrl, "stations", stationId, "variables"), + ); + return data; +}; + +export const useStationVariables = (stationId) => { + const { data: config } = useConfig(); + + return useQuery({ + queryKey: [STATION_VARIABLES_QUERY_KEY, stationId], + queryFn: () => getStationVariables({ config, stationId }), + enabled: !!config, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + }); +}; diff --git a/src/state/query-hooks/use-station.js b/src/state/query-hooks/use-station.js new file mode 100644 index 00000000..73a42399 --- /dev/null +++ b/src/state/query-hooks/use-station.js @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import urljoin from "url-join"; +import { useConfig } from "./use-config"; + +export const STATION_QUERY_KEY = "station"; + +export const getStationById = async ({ config, stationId }) => { + const { data } = await axios.get( + urljoin(config.sdsUrl, "stations", stationId), + { + transformResponse: axios.defaults.transformResponse.concat( + mapDeep(transformIso8601Date), + ), + }, + ); + return data; +}; + +export const useStation = (stationId) => { + const { data: config } = useConfig(); + + return useQuery({ + queryKey: [STATION_QUERY_KEY, stationId], + queryFn: () => getStationById({ config, stationId }), + enabled: !!config, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + }); +}; diff --git a/src/state/query-hooks/use-stations.js b/src/state/query-hooks/use-stations.js new file mode 100644 index 00000000..10f4b402 --- /dev/null +++ b/src/state/query-hooks/use-stations.js @@ -0,0 +1,60 @@ +import axios from "axios"; +import urljoin from "url-join"; +import filter from "lodash/fp/filter"; +import tap from "lodash/fp/tap"; +import isString from "lodash/fp/isString"; +import { useQuery } from "@tanstack/react-query"; +import { mapDeep } from "../../utils/fp"; +import { useConfig } from "./use-config"; +import { filterExpressionsParser, filterPredicate } from "./filtering"; + +// TODO: should this be replaced with date-fns? +// Regex for ISO 8601 date strings; allows YYYY-MM-DD with optional T spec. +// Now you've got two problems :) +const ISO_8601 = /\d{4}-\d{2}-\d{2}(Td{2}:\d{2}:\d{2})?/; + +function transformIso8601Date(value) { + // If `value` is a string that matches the ISO 8601 date format, + // transform it to a JS Date. + // Otherwise return it unmolested. + const isDateString = isString(value) && ISO_8601.test(value); + return isDateString ? new Date(Date.parse(value)) : value; +} + +export const getStations = async ({ config }) => { + const parsedStationFilterExpressions = filterExpressionsParser( + config.stationFilters, + ); + const filterStations = filter( + filterPredicate(parsedStationFilterExpressions), + ); + + const { data } = await axios.get(urljoin(config.sdsUrl, "stations"), { + params: { + offset: config.stationOffset, + limit: config.stationLimit, + stride: config.stationStride, + provinces: config.stationsQpProvinces, + }, + transformResponse: axios.defaults.transformResponse.concat( + tap((x) => console.log("raw station count", x.length)), + filterStations, + mapDeep(transformIso8601Date), + ), + }); + + return data; +}; + +export const STATIONS_QUERY_KEY = "stations"; + +export const useStations = () => { + const { data: config } = useConfig(); + + return useQuery({ + queryKey: [STATIONS_QUERY_KEY], + queryFn: () => getStations({ config }), + enabled: !!config, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + }); +}; diff --git a/src/state/query-hooks/use-variables.js b/src/state/query-hooks/use-variables.js new file mode 100644 index 00000000..a0edf3d2 --- /dev/null +++ b/src/state/query-hooks/use-variables.js @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import urljoin from "url-join"; +import { useConfig } from "./use-config"; + +export const VARIABLES_QUERY_KEY = ["variables"]; + +const getVariables = async ({ config }) => { + const { data } = await axios(urljoin(config.sdsUrl, "variables"), { + params: { + provinces: config.stationsQpProvinces, + }, + }); + return data; +}; + +export const useVariables = () => { + const { data: config } = useConfig(); + return useQuery({ + queryKey: VARIABLES_QUERY_KEY, + queryFn: () => getVariables({ config }), + enabled: !!config, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + }); +}; + +export default useVariables; diff --git a/src/state/slice-metadata.js b/src/state/slice-metadata.js deleted file mode 100644 index c95f3dc4..00000000 --- a/src/state/slice-metadata.js +++ /dev/null @@ -1,93 +0,0 @@ -import { - getStations, - getNetworks, - getVariables, - getFrequencies, -} from "../api/metadata"; - -const loadStationsAction = (set, get) => async () => { - if (!get().isConfigLoaded()) { - throw new Error("Cannot load stations until config is loaded"); - } - const config = get().config; - console.log("### loading stations"); - set({ stations: null }); - const response = await getStations({ - config, - getParams: { - compact: true, - ...(config.stationDebugFetchOptions && { limit: get().stationsLimit }), - }, - }); - console.log("### stations loaded"); - set({ stations: response.data }); -}; - -const clearMetadataAction = (set) => () => { - set({ - stations: null, - networks: null, - variables: null, - frequencies: null, - }); -}; - -const loadMetadataAction = (set, get) => async () => { - if (!get().isConfigLoaded()) { - throw new Error("Cannot load stations until config is loaded"); - } - const config = get().config; - console.log("### loading metadata"); - get().clearMetadata(); - set({ loadingMeta: true }); - const pNetworks = getNetworks({ config }); - const pVariables = getVariables({ config }); - const pFrequencies = getFrequencies({ config }); - const response = await Promise.all([pNetworks, pVariables, pFrequencies]); - set({ - loadingMeta: false, - networks: response[0].data, - variables: response[1].data, - frequencies: response[2].data, - }); -}; - -export const createMetadataSlice = (set, get) => ({ - loadingMeta: true, - - networks: null, - variables: null, - frequencies: null, - stations: null, - - stationsLimit: null, - - clearMetadata: clearMetadataAction(set), - // load actions for retrieving remote data - loadMetadata: loadMetadataAction(set, get), - loadStations: loadStationsAction(set, get), - - reloadStations: () => { - console.log("### reloadStations"); - set({ stations: null }); - get().loadStations(); - }, - - // getters - getStationById: (stationId) => { - console.log("### getStationById", stationId); - if (get().loadingMeta) { - return null; - } - console.log("### getStationById loaded", stationId); - return get().stations?.find((station) => station.id === +stationId); - }, - - // setters - setStationsLimit: (limit) => { - console.log("### setStationsLimit", limit); - set({ stationsLimit: limit }); - }, -}); - -export default createMetadataSlice; diff --git a/src/state/slice-preview.js b/src/state/slice-preview.js deleted file mode 100644 index 59c221bc..00000000 --- a/src/state/slice-preview.js +++ /dev/null @@ -1,275 +0,0 @@ -import filter from "lodash/fp/filter"; -import flow from "lodash/fp/flow"; -import map from "lodash/fp/map"; -import max from "date-fns/max"; -import min from "date-fns/min"; -import isEqual from "date-fns/isEqual"; -import addMonths from "date-fns/addMonths"; -import subMonths from "date-fns/subMonths"; -import parseIso from "date-fns/parseISO"; -import { - getStationById, - getStationVariables, - getStationVariablesObservations, -} from "../api/metadata"; - -const getMaxEndDate = flow( - map("max_obs_time"), // (string []) Pluck max_obs_time from variable objects (ISO 8601 date string) - map(parseIso), // (Date []) Parse ISO 8601 date strings to JS Date objects - max, // (Date) Get the latest date -); -const getMinStartDate = flow( - map("min_obs_time"), // (string []) Pluck min_obs_time from variable objects (ISO 8601 date string) - map(parseIso), // (Date []) Parse ISO 8601 date strings to JS Date objects - min, // (Date) Get the earliest date -); - -const loadPreviewStationAction = (set, get) => async (stationId) => { - try { - if (!get().isConfigLoaded()) { - throw new Error("Cannot load stations until config is loaded"); - } - - // try to load station from existing metadata store slice - console.log("### loading station preview"); - // when changing stations we're essentially clearing an resetting the view state - get().clearRanges(); - get().clearErrors(); - get().clearStation(); - const station = get().getStationById(stationId); - - // fall back to loading from server - if (!station) { - console.log("### loading station preview station", station); - - const response = await getStationById({ - config: get().config, - stationId, - }); - - console.log("### loaded station", response.data); - // I hope this object (from /stations/:station_id) is the same as the one - // that comes back from /stations - set({ previewStation: response.data }); - } else { - set({ previewStation: station }); - } - } catch (error) { - console.error("### error loading station", error); - set({ previewStationError: error }); - } -}; - -const loadPreviewStationVariablesAction = (set, get) => async () => { - try { - if (!get().isConfigLoaded()) { - throw new Error("Cannot load stations until config is loaded"); - } - const station = get().previewStation; - - if (!station) { - throw new Error("Cannot load variables without a station"); - } - - const response = await getStationVariables({ - config: get().config, - stationId: station.id, - }); - - const variables = filter((variable) => - variable.tags.includes("observation"), - )(response.data.variables); - const maxEndDate = getMaxEndDate(variables); - const selectedStartDate = subMonths(maxEndDate, get().selectedDuration); - - console.log("### selected date range", selectedStartDate, maxEndDate); - - // set default range - set({ - selectedStartDate: selectedStartDate, - selectedEndDate: maxEndDate, - maxEndDate: maxEndDate, - minStartDate: getMinStartDate(variables), - }); - - console.log("### loaded station variables", variables); - - set({ previewStationVariables: variables }); - - // start loading observations for graphs this could be called in the component - // unsure of best practice here. Loading it earlier here in the store should make - // the data load faster and more reliably (we'll only call this once) - get().loadSPreviewStationVariablesObservations(); - } catch (error) { - console.error("### error loading station variables", error); - set({ previewStationVariablesError: error }); - } -}; - -const loadPreviewStationVariablesObservationsAction = - (set, get) => async () => { - try { - if (!get().isConfigLoaded()) { - throw new Error("Cannot load stations until config is loaded"); - } - - const { - previewStation, - previewStationVariables, - selectedStartDate, - selectedEndDate, - } = get(); - - if ( - !previewStation || - !previewStationVariables || - !selectedStartDate || - !selectedEndDate - ) { - throw new Error( - "Cannot load observations without a station, variables, or range", - ); - } - - get().clearErrors(); - get().clearObservations(); - - const requests = flow( - map("id"), // (int []) Pluck IDs from variable objects - map( - getStationVariablesObservations({ - config: get().config, - stationId: previewStation.id, - startDate: selectedStartDate, - endDate: selectedEndDate, - }), - ), // (Promise []) Get observations for each variable - )(previewStationVariables); - - console.log("### loading station preview observations", requests); - - const response = await Promise.all(requests); - set({ previewObservations: map("data")(response) }); - console.log("### final store state:", get()); - } catch (error) { - console.error("### error loading station observations", error); - set({ previewObservationsError: error }); - } - }; - -export const createPreviewSlice = (set, get) => ({ - // for simplicity's sake and while the app is small I'm keeping to a flat state structure - // this could be broken up into a more tree-like structure if the app grows and naming - // collisions become an issue. leveraging something like immer would also be a good idea - // to facilitate more complex state updates. - - // set functions also cause the store to update its dependent date from the server. ie updating selected - // data range will cause the store to fetch the data for that range. - - // null states are used to indicate that the data is not loaded - // or there is no error. If defaults are applicable they can also be set. - previewStation: null, - previewStationError: null, - previewStationVariables: null, - previewStationVariablesError: null, - previewObservations: null, - previewObservationsError: null, - - maxEndDate: null, - minStartDate: null, - - selectedStartDate: null, - selectedEndDate: null, - selectedDuration: 6, - - showLegend: true, - - /** - * - * @param {Date} date - */ - setSelectedStartDate: (date) => { - const initialDate = get().selectedStartDate; - if (!isEqual(initialDate, date)) { - set({ selectedStartDate: date, selectedEndDate: addMonths(date, 6) }); - get().loadSPreviewStationVariablesObservations(); - } - }, - - /** - * - * @param {Date} date - */ - setSelectedEndDate: (date) => { - const initialDate = get().selectedEndDate; - if (!isEqual(initialDate, date)) { - set({ selectedEndDate: date, selectedStartDate: subMonths(date, 6) }); - get().loadSPreviewStationVariablesObservations(); - } - }, - - /** - * - * @param {number} months - */ - setDurationBeforeEnd: (months) => { - const initialDuration = get().selectedDuration; - if (initialDuration !== months) { - set({ - selectedDuration: months, - selectedStartDate: subMonths(get().selectedEndDate, months), - }); - get().loadSPreviewStationVariablesObservations(); - } - }, - - // each clear function should reset the state to its initial value and its dependent data states - clearStation: () => { - set({ - previewStation: null, - previewStationVariables: null, - previewObservations: null, - }); - }, - - clearStationVariables: () => { - set({ - previewStationVariables: null, - previewObservations: null, - }); - }, - - clearObservations: () => { - set({ - previewObservations: null, - }); - }, - - clearErrors: () => { - set({ - previewStationError: null, - previewStationVariablesError: null, - previewObservationsError: null, - }); - }, - clearRanges: () => { - set({ - maxEndDate: null, - minStartDate: null, - selectedStartDate: null, - selectedEndDate: null, - selectedDuration: 6, - }); - }, - - toggleLegend: () => { - set({ showLegend: !get().showLegend }); - }, - - loadPreviewStation: loadPreviewStationAction(set, get), - loadPreviewStationVariables: loadPreviewStationVariablesAction(set, get), - loadSPreviewStationVariablesObservations: - loadPreviewStationVariablesObservationsAction(set, get), -}); - -export default createPreviewSlice; diff --git a/src/state/state-store.js b/src/state/state-store.js index bd04a3bd..15790633 100644 --- a/src/state/state-store.js +++ b/src/state/state-store.js @@ -1,18 +1,9 @@ import { create } from "zustand"; -import { createConfigSlice } from "./slice-config"; -import { createMetadataSlice } from "./slice-metadata"; import { createDebugSlice } from "./slice-debug"; -import { createPreviewSlice } from "./slice-preview"; export const useStore = create((set, get) => ({ - ...createConfigSlice(set, get), - ...createMetadataSlice(set, get), ...createDebugSlice(set, get), - ...createPreviewSlice(set, get), // Actions - initialize: async () => { - await get()._loadConfig(); - set({ stationsLimit: get().config.stationDebugFetchLimitsOptions[0] }); - }, + setStationLimit: (limit) => set({ stationsLimit: limit }), }));