Repository: dcapal/dcapal (language: rust)

Issue:
Refresh prices
Refresh prices older than 5 minutes

- Store timestamp in Redux
- Set timer in `useEffect()` in `portfolioStep`
- Show a toast upon refresh


Discussion / hints:

Fix patch:
diff --git a/dcapal-frontend/package-lock.json b/dcapal-frontend/package-lock.json
index fe8658f2..aba90f50 100644
--- a/dcapal-frontend/package-lock.json
+++ b/dcapal-frontend/package-lock.json
@@ -23,6 +23,7 @@
         "react-dom": "^18.2.0",
         "react-github-btn": "^1.4.0",
         "react-helmet": "^6.1.0",
+        "react-hot-toast": "^2.4.1",
         "react-redux": "^8.0.2",
         "react-router-dom": "^6.6.2",
         "redux-persist": "^6.0.0",
@@ -4913,6 +4914,14 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/goober": {
+      "version": "2.1.13",
+      "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.13.tgz",
+      "integrity": "sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==",
+      "peerDependencies": {
+        "csstype": "^3.0.10"
+      }
+    },
     "node_modules/graceful-fs": {
       "version": "4.2.10",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
@@ -7235,6 +7244,21 @@
         "react": ">=16.3.0"
       }
     },
+    "node_modules/react-hot-toast": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz",
+      "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==",
+      "dependencies": {
+        "goober": "^2.1.10"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "react": ">=16",
+        "react-dom": ">=16"
+      }
+    },
     "node_modules/react-is": {
       "version": "18.2.0",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
@@ -12526,6 +12550,12 @@
         "slash": "^4.0.0"
       }
     },
+    "goober": {
+      "version": "2.1.13",
+      "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.13.tgz",
+      "integrity": "sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==",
+      "requires": {}
+    },
     "graceful-fs": {
       "version": "4.2.10",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
@@ -14090,6 +14120,14 @@
         "react-side-effect": "^2.1.0"
       }
     },
+    "react-hot-toast": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz",
+      "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==",
+      "requires": {
+        "goober": "^2.1.10"
+      }
+    },
     "react-is": {
       "version": "18.2.0",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
diff --git a/dcapal-frontend/package.json b/dcapal-frontend/package.json
index 000bcfd0..e8bb5efe 100644
--- a/dcapal-frontend/package.json
+++ b/dcapal-frontend/package.json
@@ -35,6 +35,7 @@
     "react-dom": "^18.2.0",
     "react-github-btn": "^1.4.0",
     "react-helmet": "^6.1.0",
+    "react-hot-toast": "^2.4.1",
     "react-redux": "^8.0.2",
     "react-router-dom": "^6.6.2",
     "redux-persist": "^6.0.0",
@@ -63,4 +64,4 @@
     "webpack-dev-server": "^4.13.3",
     "webpack-merge": "^5.8.0"
   }
-}
\ No newline at end of file
+}
diff --git a/dcapal-frontend/src/app/config.js b/dcapal-frontend/src/app/config.js
index 2f832984..ea0ac599 100644
--- a/dcapal-frontend/src/app/config.js
+++ b/dcapal-frontend/src/app/config.js
@@ -11,3 +11,5 @@ export const MEDIA_SMALL = "only screen and (min-width: 521px)";
 export const DEMO_PF_60_40 = "60-40";
 export const DEMO_PF_ALL_SEASONS = "all-seasons";
 export const DEMO_PF_MR_RIP = "mr-rip";
+
+export const REFRESH_PRICE_INTERVAL_SEC = 5 * 60;
diff --git a/dcapal-frontend/src/app/index.js b/dcapal-frontend/src/app/index.js
index 9a5792a7..2edceeed 100644
--- a/dcapal-frontend/src/app/index.js
+++ b/dcapal-frontend/src/app/index.js
@@ -1,5 +1,6 @@
 import React, { useEffect } from "react";
 import { useDispatch } from "react-redux";
+import { Toaster } from "react-hot-toast";
 
 import { AllocationFlow } from "../components/allocationFlow";
 import { Footer } from "../components/core/footer";
@@ -27,6 +28,16 @@ export const App = () => {
   return (
     <>
       <DcaPalHelmet title={"Allocate"} />
+      <Toaster
+        position="top-center"
+        reverseOrder={false}
+        toastOptions={{
+          success: {
+            duration: 5000,
+            iconTheme: { primary: "#166534", secondary: "#f0fdf4" },
+          },
+        }}
+      />
       <div className="relative w-full h-screen">
         <div className="absolute bg-[#ededed] w-full h-[50px] top-10 -z-40" />
         <div className="absolute app-bg -z-50" />
diff --git a/dcapal-frontend/src/app/store.js b/dcapal-frontend/src/app/store.js
index 11d15290..a423f229 100644
--- a/dcapal-frontend/src/app/store.js
+++ b/dcapal-frontend/src/app/store.js
@@ -45,11 +45,20 @@ const migrations = {
       },
     };
   },
+  3: (state) => {
+    return {
+      ...state,
+      pfolio: {
+        ...state.pfolio,
+        lastPriceRefresh: Date.now(),
+      },
+    };
+  },
 };
 
 const rootConfig = {
   key: "root",
-  version: 2,
+  version: 3,
   storage,
   migrate: createMigrate(migrations, { debug: false }),
 };
diff --git a/dcapal-frontend/src/components/allocationFlow/portfolioStep/index.js b/dcapal-frontend/src/components/allocationFlow/portfolioStep/index.js
index 85a9949d..86f5ebaa 100644
--- a/dcapal-frontend/src/components/allocationFlow/portfolioStep/index.js
+++ b/dcapal-frontend/src/components/allocationFlow/portfolioStep/index.js
@@ -1,7 +1,8 @@
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
 import { useSelector, useDispatch } from "react-redux";
 import classNames from "classnames";
 import { useMediaQuery } from "@react-hook/media-query";
+import toast from "react-hot-toast";
 
 import { SearchBar } from "./searchBar";
 import { AssetCard } from "./assetCard";
@@ -9,23 +10,86 @@ import { AssetCard } from "./assetCard";
 import {
   addAsset,
   clearPortfolio,
+  setPrice,
   setQty,
+  setRefreshTime,
   setTargetWeight,
 } from "./portfolioSlice";
 
 import { setAllocationFlowStep, Step } from "../../../app/appSlice";
 import { IKImage } from "imagekitio-react";
-import { IMAGEKIT_URL, MEDIA_SMALL } from "../../../app/config";
+import {
+  IMAGEKIT_URL,
+  MEDIA_SMALL,
+  REFRESH_PRICE_INTERVAL_SEC,
+} from "../../../app/config";
 import { ICON_BAG_SVG, ICON_PIECHART_SVG } from "../../../app/images";
 import { TransactionFees } from "./transactionFees";
+import { getFetcher } from "../../../app/providers";
+
+const refreshAssetPrices = async (assets, quoteCcy, validCcys, dispatch) => {
+  console.debug("Refreshing prices (", new Date(), ")");
+
+  if (Object.keys(assets) < 1) {
+    dispatch(setRefreshTime({ time: Date.now() }));
+    return;
+  }
+
+  Object.values(assets).forEach(async (a) => {
+    const price = await getFetcher(a.provider, validCcys)(a.symbol, quoteCcy);
+    if (!price) {
+      console.warn(
+        "[ImportStep] Failed to fetch price for",
+        a.symbol,
+        `(provider: ${quoteCcy})`
+      );
+      return;
+    }
+    dispatch(setPrice({ symbol: a.symbol, price: price }));
+  });
+
+  toast.success("Refreshed prices!");
+  dispatch(setRefreshTime({ time: Date.now() }));
+};
 
 export const PortfolioStep = ({ ...props }) => {
   const [searchText, setSearchText] = useState("");
   const [isShowFees, setShowFees] = useState(false);
+
   const assetStore = useSelector((state) => state.pfolio.assets);
+  const quoteCcy = useSelector((state) => state.pfolio.quoteCcy);
+  const validCcys = useSelector((state) => state.app.currencies);
+  const lastRefreshTime = useSelector((state) => state.pfolio.lastPriceRefresh);
+
   const isMobile = !useMediaQuery(MEDIA_SMALL);
   const dispatch = useDispatch();
 
+  useEffect(() => {
+    let timeout = null;
+
+    const refreshPrices = async () => {
+      const now = new Date();
+      const nextRefresh = new Date(
+        lastRefreshTime + REFRESH_PRICE_INTERVAL_SEC * 1000
+      );
+
+      if (now > nextRefresh) {
+        await refreshAssetPrices(assetStore, quoteCcy, validCcys, dispatch);
+        return;
+      }
+
+      timeout = setTimeout(async () => {
+        await refreshAssetPrices(assetStore, quoteCcy, validCcys, dispatch);
+      }, nextRefresh - now);
+    };
+
+    refreshPrices().catch(console.error);
+
+    return () => {
+      if (timeout) clearTimeout(timeout);
+    };
+  }, [lastRefreshTime]);
+
   const assets = Object.values(assetStore).sort((a, b) => a.idx - b.idx);
   let cumWeight = 0;
   const validity = assets.map((a) => {
@@ -193,21 +257,27 @@ export const PortfolioStep = ({ ...props }) => {
           </div>
         )}
       {Object.keys(assetStore).length > 0 && (
-        <div className="w-full mt-6 flex justify-between items-center">
-          <span
-            className="font-medium underline cursor-pointer"
-            onClick={onClickDiscard}
-          >
-            Discard
-          </span>
-          <button
-            className="px-3 pt-1.5 pb-2 flex justify-center items-center bg-neutral-500 hover:bg-neutral-600 active:bg-neutral-800 text-white text-lg rounded-md shadow-md disabled:pointer-events-none disabled:opacity-60"
-            onClick={onClickAddLiquidity}
-            disabled={!isAllAllocated}
-          >
-            Next
-          </button>
-        </div>
+        <>
+          <p className="mt-6 font-thin text-xs">
+            Prices last fetched at{" "}
+            {new Date(lastRefreshTime).toLocaleString("en-US")}
+          </p>
+          <div className="w-full mt-6 flex justify-between items-center">
+            <span
+              className="font-medium underline cursor-pointer"
+              onClick={onClickDiscard}
+            >
+              Discard
+            </span>
+            <button
+              className="px-3 pt-1.5 pb-2 flex justify-center items-center bg-neutral-500 hover:bg-neutral-600 active:bg-neutral-800 text-white text-lg rounded-md shadow-md disabled:pointer-events-none disabled:opacity-60"
+              onClick={onClickAddLiquidity}
+              disabled={!isAllAllocated}
+            >
+              Next
+            </button>
+          </div>
+        </>
       )}
     </div>
   );
diff --git a/dcapal-frontend/src/components/allocationFlow/portfolioStep/portfolioSlice.js b/dcapal-frontend/src/components/allocationFlow/portfolioStep/portfolioSlice.js
index b420b937..c093c621 100644
--- a/dcapal-frontend/src/components/allocationFlow/portfolioStep/portfolioSlice.js
+++ b/dcapal-frontend/src/components/allocationFlow/portfolioStep/portfolioSlice.js
@@ -118,6 +118,7 @@ export const portfolioSlice = createSlice({
     totalAmount: 0,
     budget: 0,
     fees: getDefaultFees(FeeType.ZERO_FEE),
+    lastPriceRefresh: Date.now(),
   },
   reducers: {
     addAsset: (state, action) => {
@@ -142,6 +143,10 @@ export const portfolioSlice = createSlice({
         },
       };
       state.nextIdx += 1;
+
+      if (Object.keys(state.assets).length === 1) {
+        state.lastPriceRefresh = new Date();
+      }
     },
     removeAsset: (state, action) => {
       const symbol = action.payload.symbol;
@@ -181,6 +186,28 @@ export const portfolioSlice = createSlice({
         updateWeight(asset, state.totalAmount);
       });
     },
+    setPrice: (state, action) => {
+      const { symbol, price } = action.payload;
+      if (!symbol || !(symbol in state.assets)) {
+        return;
+      }
+
+      const asset = state.assets[action.payload.symbol];
+
+      // Refresh amounts
+      const newAmount = (asset.qty || 0) * price;
+      state.totalAmount -= asset.qty * price;
+      state.totalAmount += newAmount;
+      state.totalAmount = state.totalAmount;
+
+      // Update asset info
+      asset.price = price;
+      asset.amount = newAmount;
+
+      Object.values(state.assets).forEach((asset) => {
+        updateWeight(asset, state.totalAmount);
+      });
+    },
     setTargetWeight: (state, action) => {
       state.assets = {
         ...state.assets,
@@ -190,6 +217,11 @@ export const portfolioSlice = createSlice({
         },
       };
     },
+    setRefreshTime: (state, action) => {
+      if (action.payload.time) {
+        state.lastPriceRefresh = action.payload.time;
+      }
+    },
     setQuoteCurrency: (state, action) => {
       if (action.payload.quoteCcy && action.payload.quoteCcy.length > 0) {
         state.quoteCcy = action.payload.quoteCcy;
@@ -340,6 +372,7 @@ export const portfolioSlice = createSlice({
       state.totalAmount = 0;
       state.budget = 0;
       state.fees = getDefaultFees(FeeType.ZERO_FEE);
+      state.lastPriceRefresh = Date.now();
     },
     clearBudget: (state) => {
       state.budget = 0;
@@ -351,7 +384,9 @@ export const {
   addAsset,
   removeAsset,
   setQty,
+  setPrice,
   setTargetWeight,
+  setRefreshTime,
   setQuoteCurrency,
   setBudget,
   setFees,


Test patch: