From c168157f576ff5082fdbc21b30c90cef318896e1 Mon Sep 17 00:00:00 2001 From: Mario Date: Fri, 16 Feb 2024 13:52:29 +0100 Subject: [PATCH] Feat: Add Epoch section on Landing (#1135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add more util functions in novaTimeUtils. Fix novaTimeUtils to be aliged with the go implementation (https://github.com/iotaledger/iota.go/blob/develop/timeprovider.go) * feat: Add nova Landing page. Add mock "epoch section" to landing (WiP) * feat: Add utils to get the registration slotIndex from an epoch index * feat: Wire up epoch stats in LandingEpochSection --------- Co-authored-by: Begoña Álvarez de la Cruz --- .../nova/landing/LandingEpochSection.scss | 81 ++++++++++++++ .../nova/landing/LandingEpochSection.tsx | 63 +++++++++++ client/src/app/routes.tsx | 2 + .../src/app/routes/nova/landing/Landing.scss | 100 ++++++++++++++++++ .../src/app/routes/nova/landing/Landing.tsx | 38 +++++++ .../nova/hooks/useCurrentEpochProgress.ts | 63 +++++++++++ .../helpers/nova/hooks/useNovaTimeConvert.ts | 9 ++ client/src/helpers/nova/novaTimeUtils.spec.ts | 72 +++++++++++-- client/src/helpers/nova/novaTimeUtils.ts | 72 ++++++++++++- 9 files changed, 490 insertions(+), 10 deletions(-) create mode 100644 client/src/app/components/nova/landing/LandingEpochSection.scss create mode 100644 client/src/app/components/nova/landing/LandingEpochSection.tsx create mode 100644 client/src/app/routes/nova/landing/Landing.scss create mode 100644 client/src/app/routes/nova/landing/Landing.tsx create mode 100644 client/src/helpers/nova/hooks/useCurrentEpochProgress.ts diff --git a/client/src/app/components/nova/landing/LandingEpochSection.scss b/client/src/app/components/nova/landing/LandingEpochSection.scss new file mode 100644 index 000000000..50d57c36c --- /dev/null +++ b/client/src/app/components/nova/landing/LandingEpochSection.scss @@ -0,0 +1,81 @@ +@import "../../../../scss/variables"; +@import "../../../../scss/fonts"; + +.epoch-section { + font-family: $metropolis; + margin-top: 40px; + background-color: $gray-1; + border-radius: 8px; + + .epoch-progress__wrapper { + display: flow-root; + background-color: $gray-3; + margin: 20px; + border-radius: 8px; + + .epoch-progress__header { + width: fit-content; + margin: 0 auto; + padding: 20px; + } + + .epoch-progress__stats-wrapper { + display: flex; + padding: 12px; + flex-direction: row; + justify-content: space-evenly; + + .epoch-progress__stat { + width: 160px; + background-color: $gray-4; + padding: 10px 20px; + border-radius: 8px; + text-align: center; + } + } + + .progress-bar__wrapper { + $bar-height: 32px; + + .progress-bar { + position: relative; + background-color: $gray-5; + margin: 20px 12px; + height: $bar-height; + border-radius: 4px; + text-align: center; + overflow: hidden; + + .progress-bar__label { + position: absolute; + left: 0; + right: 0; + line-height: $bar-height; + margin: 0 auto; + font-weight: 600; + } + + .progress-bar__fill { + position: absolute; + width: 100%; + height: 100%; + background-color: #36c636; + transform: translateX(-100%); + } + } + } + } + + .epoch-section__controls { + display: flex; + margin: 20px; + flex-direction: row; + justify-content: space-between; + + .epoch-section__button { + background-color: $gray-4; + padding: 10px 20px; + border-radius: 8px; + } + } +} diff --git a/client/src/app/components/nova/landing/LandingEpochSection.tsx b/client/src/app/components/nova/landing/LandingEpochSection.tsx new file mode 100644 index 000000000..249d2f422 --- /dev/null +++ b/client/src/app/components/nova/landing/LandingEpochSection.tsx @@ -0,0 +1,63 @@ +import moment from "moment"; +import React from "react"; +import { useCurrentEpochProgress } from "~/helpers/nova/hooks/useCurrentEpochProgress"; +import "./LandingEpochSection.scss"; + +const LandingEpochSection: React.FC = () => { + const { epochIndex, epochUnixTimeRange, epochProgressPercent, registrationTime } = useCurrentEpochProgress(); + + if (epochIndex === null || epochProgressPercent === null) { + return null; + } + + let registrationTimeRemaining = "???"; + let epochTimeRemaining = "???"; + let epochFrom = "???"; + let epochTo = "???"; + + if (epochUnixTimeRange && registrationTime) { + const epochStartTime = moment.unix(epochUnixTimeRange.from); + const epochEndTime = moment.unix(epochUnixTimeRange.to - 1); + epochFrom = epochStartTime.format("DD MMM HH:mm:ss"); + epochTo = epochEndTime.format("DD MMM HH:mm:ss"); + + const diffToEpochEnd = epochEndTime.diff(moment()); + epochTimeRemaining = moment(diffToEpochEnd).format("H:mm:ss"); + + registrationTimeRemaining = moment.unix(registrationTime).fromNow(); + } + + return ( +
+
+

Epoch {epochIndex} Progress

+
+
Registration end: {registrationTimeRemaining}
+
Time remaining: {epochTimeRemaining}
+
+ {epochFrom} +
+ {epochTo} +
+
+ +
+
+
previous
+
view more
+
next
+
+
+ ); +}; + +const ProgressBar: React.FC<{ progress: number }> = ({ progress }) => ( +
+
+
+
{progress}%
+
+
+); + +export default LandingEpochSection; diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index 4654cdbb7..cd52d8ed5 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -30,6 +30,7 @@ import NovaAddressPage from "./routes/nova/AddressPage"; import StardustBlock from "./routes/stardust/Block"; import StardustFoundry from "./routes/stardust/Foundry"; import { Landing as StardustLanding } from "./routes/stardust/landing/Landing"; +import NovaLanding from "./routes/nova/landing/Landing"; import NftRedirectRoute from "./routes/stardust/NftRedirectRoute"; import StardustOutputList from "./routes/stardust/OutputList"; import StardustOutputPage from "./routes/stardust/OutputPage"; @@ -171,6 +172,7 @@ const buildAppRoutes = (protocolVersion: string, withNetworkContext: (wrappedCom ]; const novaRoutes = [ + , , , , diff --git a/client/src/app/routes/nova/landing/Landing.scss b/client/src/app/routes/nova/landing/Landing.scss new file mode 100644 index 000000000..f2f98ef0f --- /dev/null +++ b/client/src/app/routes/nova/landing/Landing.scss @@ -0,0 +1,100 @@ +@import "../../../../scss/fonts"; +@import "../../../../scss/mixins"; +@import "../../../../scss/media-queries"; +@import "../../../../scss/variables"; +@import "../../../../scss/themes"; + +.landing-nova { + display: flex; + flex-direction: column; + + .header-wrapper { + position: relative; + z-index: 0; + overflow: hidden; + background: var(--header-bg); + + .inner { + position: relative; + display: flex; + justify-content: center; + flex: 1; + + .header { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + min-height: 400px; + + @include desktop-down { + justify-content: start; + } + + @include tablet-down { + justify-content: center; + } + + .header--title { + text-align: center; + + @include desktop-down { + margin: 0; + margin-bottom: 28px; + margin-left: 160px; + } + + @include tablet-down { + margin-bottom: 172px; + margin-left: 0px; + } + + h1 { + @include font-size(48px, 58px); + + font-family: $metropolis-semi-bold; + letter-spacing: 0.02em; + + @include phone-down { + @include font-size(32px, 36px); + } + } + + h2 { + @include font-size(16px, 24px); + + color: $mint-green-6; + font-family: $metropolis-bold; + letter-spacing: 0.15em; + text-transform: uppercase; + } + + .network-name { + text-align: center; + } + } + + .switcher { + margin: 0 20px 20px 20px; + } + + @include phone-down { + width: 100%; + } + } + } + } + + .wrapper { + display: flex; + justify-content: center; + padding: 0 $inner-padding 44px; + + .inner { + display: flex; + flex: 1; + flex-direction: column; + max-width: 960px; + } + } +} diff --git a/client/src/app/routes/nova/landing/Landing.tsx b/client/src/app/routes/nova/landing/Landing.tsx new file mode 100644 index 000000000..68cc9158a --- /dev/null +++ b/client/src/app/routes/nova/landing/Landing.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { RouteComponentProps } from "react-router-dom"; +import LandingEpochSection from "~/app/components/nova/landing/LandingEpochSection"; +import { useNetworkConfig } from "~helpers/hooks/useNetworkConfig"; +import { LandingRouteProps } from "../../LandingRouteProps"; +import "./Landing.scss"; + +const Landing: React.FC> = ({ + match: { + params: { network }, + }, +}) => { + const [networkConfig] = useNetworkConfig(network); + + return ( +
+
+
+
+
+

{networkConfig.isEnabled ? "Explore network" : ""}

+
+

{networkConfig.label}

+
+
+
+
+
+
+
+ +
+
+
+ ); +}; + +export default Landing; diff --git a/client/src/helpers/nova/hooks/useCurrentEpochProgress.ts b/client/src/helpers/nova/hooks/useCurrentEpochProgress.ts new file mode 100644 index 000000000..c838c8a9b --- /dev/null +++ b/client/src/helpers/nova/hooks/useCurrentEpochProgress.ts @@ -0,0 +1,63 @@ +import moment from "moment"; +import { useEffect, useState } from "react"; +import { useNovaTimeConvert } from "./useNovaTimeConvert"; + +export function useCurrentEpochProgress(): { + epochIndex: number | null; + epochUnixTimeRange: { from: number; to: number } | null; + epochProgressPercent: number | null; + registrationTime: number | null; +} { + const { slotIndexToUnixTimeRange, unixTimestampToEpochIndex, epochIndexToUnixTimeRange, getRegistrationSlotFromEpochIndex } = + useNovaTimeConvert(); + const [intervalTimerHandle, setIntervalTimerHandle] = useState(null); + const [epochIndex, setEpochIndex] = useState(null); + const [epochProgressPercent, setEpochProgressPercent] = useState(null); + const [registrationTime, setRegistrationTime] = useState(null); + const [epochUnixTimeRange, setEpochUnixTimeRange] = useState<{ from: number; to: number } | null>(null); + + useEffect(() => { + if (intervalTimerHandle === null) { + checkCurrentEpochIndex(); + + const intervalTimerHandle = setInterval(() => { + checkCurrentEpochIndex(); + }, 1000); + + setIntervalTimerHandle(intervalTimerHandle); + } + + return () => { + if (intervalTimerHandle) { + clearInterval(intervalTimerHandle); + } + setIntervalTimerHandle(null); + setEpochIndex(null); + }; + }, []); + + const checkCurrentEpochIndex = () => { + if (unixTimestampToEpochIndex && epochIndexToUnixTimeRange) { + const now = moment().unix(); + const currentEpochIndex = unixTimestampToEpochIndex(now); + + const epochTimeRange = epochIndexToUnixTimeRange(currentEpochIndex); + + const epochProgressPercent = Math.trunc(((now - epochTimeRange.from) / (epochTimeRange.to - 1 - epochTimeRange.from)) * 100); + + setEpochIndex(currentEpochIndex); + setEpochUnixTimeRange(epochTimeRange); + setEpochProgressPercent(epochProgressPercent); + } + }; + + useEffect(() => { + if (getRegistrationSlotFromEpochIndex && slotIndexToUnixTimeRange && epochIndex !== null) { + const slotIndex = getRegistrationSlotFromEpochIndex(epochIndex); + const slotTimeRange = slotIndexToUnixTimeRange(slotIndex); + setRegistrationTime(slotTimeRange.to - 1); + } + }, [epochIndex]); + + return { epochIndex, epochUnixTimeRange, epochProgressPercent, registrationTime }; +} diff --git a/client/src/helpers/nova/hooks/useNovaTimeConvert.ts b/client/src/helpers/nova/hooks/useNovaTimeConvert.ts index a4da973f3..d8b7b4ffb 100644 --- a/client/src/helpers/nova/hooks/useNovaTimeConvert.ts +++ b/client/src/helpers/nova/hooks/useNovaTimeConvert.ts @@ -4,6 +4,9 @@ import { slotIndexToUnixTimeRangeConverter, unixTimestampToEpochIndexConverter, unixTimestampToSlotIndexConverter, + epochIndexToSlotIndexRangeConverter, + epochIndexToUnixTimeRangeConverter, + getRegistrationSlotFromEpochIndex, } from "../novaTimeUtils"; export function useNovaTimeConvert(): { @@ -11,6 +14,9 @@ export function useNovaTimeConvert(): { slotIndexToUnixTimeRange: ((slotIndex: number) => { from: number; to: number }) | null; slotIndexToEpochIndex: ((targetSlotIndex: number) => number) | null; unixTimestampToEpochIndex: ((unixTimestampSeconds: number) => number) | null; + epochIndexToSlotIndexRange: ((targetEpochIndex: number) => { from: number; to: number }) | null; + epochIndexToUnixTimeRange: ((targetEpochIndex: number) => { from: number; to: number }) | null; + getRegistrationSlotFromEpochIndex: ((targetEpochIndex: number) => number) | null; } { const { protocolInfo } = useNetworkInfoNova((s) => s.networkInfo); @@ -19,5 +25,8 @@ export function useNovaTimeConvert(): { slotIndexToUnixTimeRange: protocolInfo ? slotIndexToUnixTimeRangeConverter(protocolInfo) : null, slotIndexToEpochIndex: protocolInfo ? slotIndexToEpochIndexConverter(protocolInfo) : null, unixTimestampToEpochIndex: protocolInfo ? unixTimestampToEpochIndexConverter(protocolInfo) : null, + epochIndexToSlotIndexRange: protocolInfo ? epochIndexToSlotIndexRangeConverter(protocolInfo) : null, + epochIndexToUnixTimeRange: protocolInfo ? epochIndexToUnixTimeRangeConverter(protocolInfo) : null, + getRegistrationSlotFromEpochIndex: protocolInfo ? getRegistrationSlotFromEpochIndex(protocolInfo) : null, }; } diff --git a/client/src/helpers/nova/novaTimeUtils.spec.ts b/client/src/helpers/nova/novaTimeUtils.spec.ts index 4821ec04c..0b26f9585 100644 --- a/client/src/helpers/nova/novaTimeUtils.spec.ts +++ b/client/src/helpers/nova/novaTimeUtils.spec.ts @@ -4,6 +4,9 @@ import { slotIndexToUnixTimeRangeConverter, slotIndexToEpochIndexConverter, unixTimestampToEpochIndexConverter, + epochIndexToUnixTimeRangeConverter, + epochIndexToSlotIndexRangeConverter, + getRegistrationSlotFromEpochIndex, } from "./novaTimeUtils"; const mockProtocolInfo: ProtocolInfo = { @@ -48,6 +51,9 @@ const unixTimestampToSlotIndex = unixTimestampToSlotIndexConverter(mockProtocolI const slotIndexToUnixTimeRange = slotIndexToUnixTimeRangeConverter(mockProtocolInfo); const slotIndexToEpochIndex = slotIndexToEpochIndexConverter(mockProtocolInfo); const unixTimestampToEpochIndex = unixTimestampToEpochIndexConverter(mockProtocolInfo); +const epochIndexToSlotIndexRange = epochIndexToSlotIndexRangeConverter(mockProtocolInfo); +const epochIndexToUnixTimeRange = epochIndexToUnixTimeRangeConverter(mockProtocolInfo); +const getRegistrationSlot = getRegistrationSlotFromEpochIndex(mockProtocolInfo); describe("unixTimestampToSlotIndex", () => { test("should return genesis slot when timestamp is lower than genesisUnixTimestamp", () => { @@ -204,28 +210,28 @@ describe("slotIndexToUnixTimeRange & unixTimestampToSlotIndex", () => { }); describe("slotIndexToEpochIndex", () => { - test("should return epoch 0 for slot index less then slotsInEpoch", () => { - const targetSlotIndex = slotsInEpoch - 100; + test("should return epoch 0 for slot index less then slotsInEpoch + genesisSlot", () => { + const targetSlotIndex = slotsInEpoch + genesisSlot - 1; const epochIndex = slotIndexToEpochIndex(targetSlotIndex); expect(epochIndex).toBe(0); }); - test("should return epoch 1 for slot index a bit after slotsInEpoch", () => { - const targetSlotIndex = slotsInEpoch + 100; + test("should return epoch 1 for slot index of slotsInEpoch + genesisSlot", () => { + const targetSlotIndex = slotsInEpoch + genesisSlot; const epochIndex = slotIndexToEpochIndex(targetSlotIndex); expect(epochIndex).toBe(1); }); - test("should return epoch 1 for slot index a bit after slotsInEpoch", () => { - const targetSlotIndex = 50000; + test("should return epoch 2 for slot index a bit after slotsInEpoch", () => { + const targetSlotIndex = slotsInEpoch * 2 + genesisSlot; const epochIndex = slotIndexToEpochIndex(targetSlotIndex); - expect(epochIndex).toBe(6); // 50000 / 8192 = 6.1 + expect(epochIndex).toBe(2); }); }); @@ -238,3 +244,55 @@ describe("unixTimestampToEpochIndex", () => { expect(epochIndex).toBe(2); }); }); + +describe("epochIndexToSlotIndexRange", () => { + test("should return the correct slot index range for epoch 0", () => { + const targetEpoch = 0; + + epochIndexToSlotIndexRange(targetEpoch); + + expect(epochIndexToSlotIndexRange(targetEpoch)).toStrictEqual({ + from: genesisSlot, + to: genesisSlot + slotsInEpoch, + }); + }); + + test("should return the correct slot index range for epoch 1", () => { + const targetEpoch = 1; + + const slotIndexRange = epochIndexToSlotIndexRange(targetEpoch); + + expect(slotIndexRange).toStrictEqual({ + from: genesisSlot + slotsInEpoch, + to: genesisSlot + slotsInEpoch * 2, + }); + }); +}); + +describe("epochIndexToUnixTimeRange", () => { + test("should return the correct unix time range for epoch 0", () => { + const targetEpoch = 0; + + const epochUnixTimeRange = epochIndexToUnixTimeRange(targetEpoch); + + expect(epochUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp - slotDurationInSeconds, + to: genesisUnixTimestamp + (slotsInEpoch - 1) * slotDurationInSeconds, + }); + }); +}); + +describe("getRegistrationSlotFromEpochIndex", () => { + test("should return the correct slot index given an epoch index", () => { + const epochNearingThreshold = mockProtocolInfo.parameters.epochNearingThreshold; + const currentEpoch = 5; + + const currentEpochSlotRange = epochIndexToSlotIndexRange(currentEpoch); + + const regitrationSlot = getRegistrationSlot(currentEpoch); + + expect(regitrationSlot).toBeGreaterThan(currentEpochSlotRange.from); + expect(regitrationSlot).toBeLessThan(currentEpochSlotRange.to); + expect(regitrationSlot).toBe(currentEpochSlotRange.to - epochNearingThreshold - 1); + }); +}); diff --git a/client/src/helpers/nova/novaTimeUtils.ts b/client/src/helpers/nova/novaTimeUtils.ts index 25d38e2d9..a2c244105 100644 --- a/client/src/helpers/nova/novaTimeUtils.ts +++ b/client/src/helpers/nova/novaTimeUtils.ts @@ -62,8 +62,14 @@ export function slotIndexToUnixTimeRangeConverter(protocolInfo: ProtocolInfo): ( */ export function slotIndexToEpochIndexConverter(protocolInfo: ProtocolInfo): (targetSlotIndex: number) => number { return (targetSlotIndex: number) => { + const genesisSlot = protocolInfo.parameters.genesisSlot; const slotsPerEpochExponent = protocolInfo.parameters.slotsPerEpochExponent; - return targetSlotIndex >> slotsPerEpochExponent; + + if (targetSlotIndex < genesisSlot) { + return 0; + } + + return (targetSlotIndex - genesisSlot) >>> slotsPerEpochExponent; }; } @@ -75,10 +81,70 @@ export function slotIndexToEpochIndexConverter(protocolInfo: ProtocolInfo): (tar */ export function unixTimestampToEpochIndexConverter(protocolInfo: ProtocolInfo): (unixTimestampSeconds: number) => number { return (unixTimestampSeconds: number) => { - const slotsPerEpochExponent = protocolInfo.parameters.slotsPerEpochExponent; const unixTimestampToSlotIndex = unixTimestampToSlotIndexConverter(protocolInfo); + const slotIndexToEpochIndex = slotIndexToEpochIndexConverter(protocolInfo); const targetSlotIndex = unixTimestampToSlotIndex(unixTimestampSeconds); - return targetSlotIndex >> slotsPerEpochExponent; + + return slotIndexToEpochIndex(targetSlotIndex); + }; +} + +/** + * Convert an epoch index to a slot index range. + * @param protocolInfo The protocol information. + * @param targetEpochIndex The target epoch index. + * @returns The slot index range in seconds: from (inclusive) and to (exclusive). + */ +export function epochIndexToSlotIndexRangeConverter( + protocolInfo: ProtocolInfo, +): (targetEpochIndex: number) => { from: number; to: number } { + return (targetEpochIndex: number) => { + const slotsPerEpochExponent = protocolInfo.parameters.slotsPerEpochExponent; + const genesisSlot = protocolInfo.parameters.genesisSlot; + + return { + from: genesisSlot + (targetEpochIndex << slotsPerEpochExponent), + to: genesisSlot + ((targetEpochIndex + 1) << slotsPerEpochExponent), + }; + }; +} + +/** + * Convert an epoch index to a UNIX time range, in seconds. + * @param protocolInfo The protocol information. + * @param targetEpochIndex The target epoch index. + * @returns The UNIX time range in seconds: from (inclusive) and to (exclusive). + */ +export function epochIndexToUnixTimeRangeConverter(protocolInfo: ProtocolInfo): (targetEpochIndex: number) => { from: number; to: number } { + return (targetEpochIndex: number) => { + const epochIndexToSlotIndexRange = epochIndexToSlotIndexRangeConverter(protocolInfo); + const slotIndexToUnixTimeRange = slotIndexToUnixTimeRangeConverter(protocolInfo); + + const targetEpochSlotIndexRange = epochIndexToSlotIndexRange(targetEpochIndex); + + return { + from: slotIndexToUnixTimeRange(targetEpochSlotIndexRange.from).from, + to: slotIndexToUnixTimeRange(targetEpochSlotIndexRange.to).from, + }; + }; +} + +/** + * Get the registration slot from an epoch index. + * @param protocolInfo The protocol information. + * @param targetEpochIndex The target epoch index. + * @returns The registration slot index. + */ +export function getRegistrationSlotFromEpochIndex(protocolInfo: ProtocolInfo): (targetEpochIndex: number) => number { + return (targetEpochIndex: number) => { + const epochNearingThreshold = protocolInfo.parameters.epochNearingThreshold; + const epochIndexToSlotIndexRange = epochIndexToSlotIndexRangeConverter(protocolInfo); + + const nextEpochSlotIndexRange = epochIndexToSlotIndexRange(targetEpochIndex + 1); + + const registrationSlot = nextEpochSlotIndexRange.from - epochNearingThreshold - 1; + + return registrationSlot; }; }