Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Provide the user with the time information regarding when the current epoch ends and how long until an eviction expires. #380

Merged
merged 9 commits into from
Oct 4, 2024
44 changes: 33 additions & 11 deletions frontend/components/MainPage/header/CannotStartAgentPopover.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Popover, PopoverProps, Typography } from 'antd';
import { Flex, Popover, PopoverProps, Typography } from 'antd';

import { COLOR } from '@/constants/colors';
import { UNICODE_SYMBOLS } from '@/constants/symbols';
import { SUPPORT_URL } from '@/constants/urls';
import { useStakingContractInfo } from '@/hooks/useStakingContractInfo';
import { formatToShortDateTime } from '@/utils/time';

const { Paragraph, Text } = Typography;

Expand Down Expand Up @@ -42,16 +43,37 @@ export const CannotStartAgentDueToUnexpectedError = () => (
);

const evictedDescription =
"You didn't run your agent enough and it missed its targets multiple times. Please wait a few days and try to run your agent again.";
const AgentEvictedPopover = () => (
<Popover
{...otherPopoverProps}
title="Your agent is suspended from work"
content={<div style={{ maxWidth: 340 }}>{evictedDescription}</div>}
>
{cannotStartAgentText}
</Popover>
);
"You didn't run your agent enough and it missed its targets multiple times. You can run the agent again when the eviction period ends.";
const AgentEvictedPopover = () => {
const { evictionExpiresAt } = useStakingContractInfo();

return (
<Popover
{...otherPopoverProps}
title="Your agent is evicted"
content={
<Flex
vertical
gap={8}
className="text-sm-all"
style={{ maxWidth: 340 }}
>
<Paragraph className="text-sm m-0">{evictedDescription}</Paragraph>
{evictionExpiresAt && (
<Paragraph className="m-0">
<Text className="text-sm">Eviction ends at</Text>{' '}
<Text strong className="text-sm">
{formatToShortDateTime(evictionExpiresAt * 1000)}
</Text>
</Paragraph>
)}
</Flex>
}
>
{cannotStartAgentText}
</Popover>
);
};

const JoinOlasCommunity = () => (
<div style={{ maxWidth: 340 }}>
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/MainPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { KeepAgentRunningSection } from './sections/KeepAgentRunningSection';
import { MainNeedsFunds } from './sections/NeedsFundsSection';
import { NewStakingProgramAlertSection } from './sections/NewStakingProgramAlertSection';
import { MainOlasBalance } from './sections/OlasBalanceSection';
import { MainRewards } from './sections/RewardsSection';
import { MainRewards } from './sections/RewardsSection/RewardsSection';
import { StakingContractUpdate } from './sections/StakingContractUpdate';

export const Main = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { InfoCircleOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Flex, Modal, Skeleton, Tag, Tooltip, Typography } from 'antd';
import { RightOutlined } from '@ant-design/icons';
import { Button, Flex, Modal, Skeleton, Tag, Typography } from 'antd';
import Image from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';

Expand All @@ -11,10 +11,11 @@ import { useReward } from '@/hooks/useReward';
import { useStore } from '@/hooks/useStore';
import { balanceFormat } from '@/utils/numberFormatters';

import { ConfettiAnimation } from '../../Confetti/ConfettiAnimation';
import { CardSection } from '../../styled/CardSection';
import { ConfettiAnimation } from '../../../Confetti/ConfettiAnimation';
import { CardSection } from '../../../styled/CardSection';
import { StakingRewardsThisEpoch } from './StakingRewardsThisEpoch';

const { Text, Title, Paragraph } = Typography;
const { Text, Title } = Typography;

const Loader = () => (
<Flex vertical gap={8}>
Expand All @@ -35,20 +36,7 @@ const DisplayRewards = () => {

return (
<CardSection vertical gap={8} padding="16px 24px" align="start">
<Text type="secondary">
Staking rewards this epoch&nbsp;
<Tooltip
arrow={false}
title={
<Paragraph className="text-sm m-0">
The agent&apos;s working period lasts at least 24 hours, but its
start and end point may not be at the same time every day.
</Paragraph>
}
>
<InfoCircleOutlined />
</Tooltip>
</Text>
<StakingRewardsThisEpoch />

{isBalanceLoaded ? (
<Flex align="center" gap={12}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { Popover, Typography } from 'antd';
import { gql, request } from 'graphql-request';
import { z } from 'zod';

import { SUBGRAPH_URL } from '@/constants/urls';
import { useStakingProgram } from '@/hooks/useStakingProgram';
import { formatToTime } from '@/utils/time';

const { Text } = Typography;

const EpochTimeResponseSchema = z.object({
epochLength: z.string(),
blockTimestamp: z.string(),
});
type EpochTimeResponse = z.infer<typeof EpochTimeResponseSchema>;

const useEpochEndTime = () => {
const { activeStakingProgramAddress } = useStakingProgram();
const latestEpochTimeQuery = gql`
query {
checkpoints(
orderBy: epoch
orderDirection: desc
first: 1
where: {
contractAddress: "${activeStakingProgramAddress}"
}
) {
epochLength
blockTimestamp
}
}
`;

const { data, isLoading } = useQuery({
queryKey: ['latestEpochTime'],
queryFn: async () => {
const response = (await request(SUBGRAPH_URL, latestEpochTimeQuery)) as {
checkpoints: EpochTimeResponse[];
};
return EpochTimeResponseSchema.parse(response.checkpoints[0]);
},
select: (data) => {
// last epoch end time + epoch length
return Number(data.blockTimestamp) + Number(data.epochLength);
},
enabled: !!activeStakingProgramAddress,
});

return { data, isLoading };
};

export const StakingRewardsThisEpoch = () => {
const { data: epochEndTimeInMs } = useEpochEndTime();

return (
<Text type="secondary">
Staking rewards this epoch&nbsp;
<Popover
arrow={false}
content={
<>
The epoch ends each day at ~{' '}
<Text className="text-sm" strong>
{epochEndTimeInMs
? `${formatToTime(epochEndTimeInMs * 1000)} (UTC)`
: '--'}
</Text>
</>
}
>
<InfoCircleOutlined />
</Popover>
</Text>
);
};
4 changes: 1 addition & 3 deletions frontend/components/RewardsHistory/useRewardsHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { z } from 'zod';
import { Chain } from '@/client';
import { SERVICE_STAKING_TOKEN_MECH_USAGE_CONTRACT_ADDRESSES } from '@/constants/contractAddresses';
import { STAKING_PROGRAM_META } from '@/constants/stakingProgramMeta';
import { SUBGRAPH_URL } from '@/constants/urls';
import { StakingProgramId } from '@/enums/StakingProgram';
import { useServices } from '@/hooks/useServices';

Expand All @@ -30,9 +31,6 @@ const beta2Address =
SERVICE_STAKING_TOKEN_MECH_USAGE_CONTRACT_ADDRESSES[Chain.GNOSIS]
.pearl_beta_2;

const SUBGRAPH_URL =
'https://api.studio.thegraph.com/query/81855/pearl-staking-rewards-history/version/latest';

const fetchRewardsQuery = gql`
{
allRewards: checkpoints(orderBy: epoch, orderDirection: desc) {
Expand Down
3 changes: 3 additions & 0 deletions frontend/constants/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ export const COW_SWAP_GNOSIS_XDAI_OLAS_URL: string =
export const SUPPORT_URL =
'https://discord.com/channels/899649805582737479/1244588374736502847';
export const FAQ_URL = 'https://olas.network/operate#faq';

export const SUBGRAPH_URL =
'https://api.studio.thegraph.com/query/81855/pearl-staking-rewards-history/version/latest';
7 changes: 5 additions & 2 deletions frontend/hooks/useStakingContractInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,7 @@ export const useStakingContractInfo = () => {
serviceIds.length < maxNumServices;

const hasEnoughRewardsAndSlots = isRewardsAvailable && hasEnoughServiceSlots;

const isAgentEvicted = serviceStakingState === 2;

const isServiceStaked =
!!serviceStakingStartTime && serviceStakingState === 1;

Expand Down Expand Up @@ -78,10 +76,15 @@ export const useStakingContractInfo = () => {
!isNil(hasEnoughRewardsAndSlots) &&
(isAgentEvicted ? isServiceStakedForMinimumDuration : true);

// Eviction expire time in seconds
const evictionExpiresAt =
(serviceStakingStartTime ?? 0) + (minimumStakingDuration ?? 0);

return {
activeStakingContractInfo,
hasEnoughServiceSlots,
isAgentEvicted,
evictionExpiresAt,
isEligibleForStaking,
isPaused,
isRewardsAvailable,
Expand Down
12 changes: 11 additions & 1 deletion frontend/hooks/useStakingProgram.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useContext, useMemo } from 'react';

import { Chain } from '@/client';
import { SERVICE_STAKING_TOKEN_MECH_USAGE_CONTRACT_ADDRESSES } from '@/constants/contractAddresses';
import { STAKING_PROGRAM_META } from '@/constants/stakingProgramMeta';
import { StakingProgramContext } from '@/context/StakingProgramContext';

Expand All @@ -23,16 +25,24 @@ export const useStakingProgram = () => {
* returns `null` if not actively staked
*/
const activeStakingProgramMeta = useMemo(() => {
if (activeStakingProgramId === undefined) return undefined;
if (activeStakingProgramId === undefined) return;
if (activeStakingProgramId === null) return null;
return STAKING_PROGRAM_META[activeStakingProgramId];
}, [activeStakingProgramId]);

const defaultStakingProgramMeta =
STAKING_PROGRAM_META[defaultStakingProgramId];

const activeStakingProgramAddress = useMemo(() => {
if (!activeStakingProgramId) return;
return SERVICE_STAKING_TOKEN_MECH_USAGE_CONTRACT_ADDRESSES[Chain.GNOSIS][
activeStakingProgramId
];
}, [activeStakingProgramId]);

return {
activeStakingProgramId,
activeStakingProgramAddress,
activeStakingProgramMeta,
defaultStakingProgramId,
defaultStakingProgramMeta,
Expand Down
4 changes: 1 addition & 3 deletions frontend/styles/globals.scss
Original file line number Diff line number Diff line change
Expand Up @@ -172,18 +172,16 @@ button, input, select, textarea, .ant-input-suffix {
margin-right: auto !important;
}

// font size
.text-xl {
font-size: 20px;
}

.text-base {
font-size: 16px !important;
}

.text-sm {
font-size: 14px !important;
}

.text-xs {
font-size: 12px !important;
}
Expand Down
3 changes: 3 additions & 0 deletions frontend/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export const mainTheme: ThemeConfig = {
Typography: {
colorTextDescription: '#4D596A',
},
Popover: {
fontSize: 14,
},
Tag: {
colorSuccess: '#135200',
},
Expand Down
26 changes: 20 additions & 6 deletions frontend/utils/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,36 @@ export const getTimeAgo = (timestampInSeconds: number) => {
* @returns formatted date in the format of 'MMM DD'
* @example 1626825600 => 'Jul 21'
*/
export const formatToMonthDay = (timeInSeconds: number) => {
if (!isNumber(timeInSeconds)) return '--';
return new Date(timeInSeconds).toLocaleDateString('en-US', {
export const formatToMonthDay = (timeInMs: number) => {
if (!isNumber(timeInMs)) return '--';
return new Date(timeInMs).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
};

/**
* @returns formatted time in the format of 'HH:MM AM/PM'
* @example 1626825600 => '12:00 PM'
*/
export const formatToTime = (timeInMs: number) => {
if (!isNumber(timeInMs)) return '--';
return new Date(timeInMs).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: 'numeric',
hour12: true,
timeZone: 'UTC',
});
};

/**
*
* @returns formatted date and time in the format of 'MMM DD, HH:MM AM/PM'
* @example 1626825600 => 'Jul 21, 12:00 PM'
*/
export const formatToShortDateTime = (timeInSeconds?: number) => {
if (!isNumber(timeInSeconds)) return '--';
return new Date(timeInSeconds).toLocaleDateString('en-US', {
export const formatToShortDateTime = (timeInMs?: number) => {
if (!isNumber(timeInMs)) return '--';
return new Date(timeInMs).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
Expand Down
Loading