├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── LICENSE ├── README.md ├── components.json ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public ├── algorand-coin.webp ├── apple-touch-icon.png ├── favicon-96x96.png ├── favicon.ico ├── favicon.svg ├── logo.png ├── logo.svg ├── preview.png ├── site.webmanifest ├── web-app-manifest-192x192.png └── web-app-manifest-512x512.png ├── screenshot.png ├── src ├── App.css ├── assets │ └── react.svg ├── components │ ├── address-input.tsx │ ├── address │ │ ├── add-address.tsx │ │ ├── address-breadcrumb.tsx │ │ ├── address-filters.tsx │ │ ├── address-view.tsx │ │ ├── charts │ │ │ ├── block-reward-intervals.tsx │ │ │ ├── cumulative-blocks-chart.tsx │ │ │ ├── cumulative-rewards-chart.tsx │ │ │ └── reward-by-day-hour-chart.tsx │ │ ├── csv-export-dialog.tsx │ │ ├── search-bar.tsx │ │ ├── settings.tsx │ │ └── stats │ │ │ ├── panel.tsx │ │ │ ├── panels │ │ │ ├── apy-panel.tsx │ │ │ ├── blocks-per-day-panel.tsx │ │ │ ├── rewards-per-day-panel.tsx │ │ │ └── total-panel.tsx │ │ │ ├── percentage-change.tsx │ │ │ ├── stat-box.tsx │ │ │ ├── stats-panels.tsx │ │ │ └── status │ │ │ ├── anticipated-time-between-blocks-badge.tsx │ │ │ ├── anxiety-box.tsx │ │ │ ├── anxiety-card.tsx │ │ │ ├── anxiety-gauge.tsx │ │ │ ├── balance-badge.tsx │ │ │ ├── balance-card.tsx │ │ │ ├── incentive-eligibility-badge.tsx │ │ │ ├── last-block-proposed-badge.tsx │ │ │ ├── last-heartbeat-badge.tsx │ │ │ ├── participation-key-badge.tsx │ │ │ ├── status-badge.tsx │ │ │ └── status.tsx │ ├── algo-amount-display.tsx │ ├── algorand-logo.tsx │ ├── coins-spread.tsx │ ├── copy-to-clipboard.tsx │ ├── dot-badge.tsx │ ├── error.tsx │ ├── explorer-link.tsx │ ├── footer.tsx │ ├── github-corner.tsx │ ├── heatmap │ │ ├── day-view.tsx │ │ ├── heatmap.tsx │ │ ├── month-view.tsx │ │ └── types.ts │ ├── number-display.tsx │ ├── search-bar.tsx │ ├── spinner.tsx │ ├── staking-cta.tsx │ ├── theme-provider.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── mobile-tooltip.tsx │ │ ├── popover.tsx │ │ ├── select.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── start-date-picker.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ └── tooltip.tsx ├── constants.ts ├── hooks │ ├── useAccounts.ts │ ├── useAlgoPrice.ts │ ├── useAlgorandAddress.ts │ ├── useAverageBlockTime.ts │ ├── useBlock.ts │ ├── useBlocksStats.test.ts │ ├── useBlocksStats.ts │ ├── useCurrentRound.ts │ ├── useIsSmallScreen.ts │ ├── useRewardTransactions.ts │ └── useStakeInfo.ts ├── lib │ ├── csv-columns.ts │ ├── csv-export.ts │ ├── date-utils.ts │ ├── indexer-client.ts │ ├── mockTimezone.ts │ ├── utils.test.ts │ └── utils.ts ├── main.tsx ├── queries │ ├── getAccountsBlockHeaders.ts │ └── getResolvedNFD.ts ├── routeTree.gen.ts ├── routes │ ├── $addresses.tsx │ ├── __root.tsx │ └── index.tsx └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest.config.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: 🔄 Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: 🟢 Setup Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: "22" 21 | cache: "npm" 22 | 23 | - name: 📦 Install dependencies 24 | run: npm ci 25 | 26 | - name: 🔍 Lint check 27 | run: npm run lint 28 | 29 | - name: 💅 Prettier check 30 | run: npm run prettier:check 31 | 32 | - name: 📝 Type check 33 | run: npm run type:check 34 | 35 | - name: 🏗️ Build 36 | run: npm run build 37 | 38 | - name: 🧪 Run tests 39 | run: npm run test 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 @Cryptomalgo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AlgoNodeRewards 2 | 3 | [![CI](https://github.com/cryptomalgo/algonoderewards/actions/workflows/ci.yml/badge.svg)](https://github.com/cryptomalgo/algonoderewards/actions/workflows/ci.yml) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) 5 | 6 | [![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB)](https://react.dev/) 7 | [![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) 8 | [![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)](https://vitejs.dev/) 9 | 10 | [![Follow on X](https://img.shields.io/badge/Follow%20@cryptomalgo-000000?style=flat&logo=x&logoColor=white)](https://x.com/cryptomalgo) 11 | 12 | A React application to track and visualize the rewards from running an Algorand node, using [Nodely](https://nodely.io/) API. 13 | 14 | ## Website 15 | 16 | You can access the website at [algonoderewards.com](https://algonoderewards.com) 17 | 18 | ![app screenshot](screenshot.png) 19 | 20 | ## Features 21 | 22 | - Node status 23 | - Rewards statistics 24 | 25 | - Estimated APY (Annual Percentage Yield) 26 | - Total rewards 27 | - Total blocks 28 | - No block probability gauge 29 | - Max blocks/rewards in a day 30 | - Min/max reward 31 | - Average rewards per day/month total/last 30D/last 7D 32 | - Average blocks per day/month total/last 30D/last 7D 33 | - Monthly heatmap statistics 34 | - Rewards History chart 35 | - Blocks History chart 36 | - Block Distribution chart by Day and Hour 37 | - Block reward intervals chart 38 | 39 | - Responsive design for both desktop and mobile 40 | - Dark/light/system theme modes 41 | - Real-time exchange rate in USD (from Binance) 42 | - CSV export 43 | 44 | ## Development Setup 45 | 46 | ```bash 47 | # Install dependencies 48 | npm install 49 | 50 | # Start development server 51 | npm run dev 52 | 53 | # Lint & test 54 | npm run ci 55 | 56 | # Build for production 57 | npm run build 58 | ``` 59 | 60 | ## Deploy 61 | 62 | The project is automatically deployed to [![Deployed on Cloudflare Pages](https://img.shields.io/badge/Cloudflare%20Pages-F38020?style=flat&logo=cloudflare&logoColor=white)](https://pages.cloudflare.com/) 63 | on each push to the main branch. The production website is available at [algonoderewards.com](https://algonoderewards.com). 64 | 65 | ## License 66 | 67 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) 68 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/App.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | import eslintConfigPrettier from "eslint-config-prettier"; 7 | 8 | export default tseslint.config( 9 | { ignores: ["dist"] }, 10 | { 11 | extends: [ 12 | js.configs.recommended, 13 | ...tseslint.configs.recommended, 14 | eslintConfigPrettier, 15 | ], 16 | files: ["**/*.{ts,tsx}"], 17 | languageOptions: { 18 | ecmaVersion: 2020, 19 | globals: globals.browser, 20 | }, 21 | plugins: { 22 | "react-hooks": reactHooks, 23 | "react-refresh": reactRefresh, 24 | }, 25 | rules: { 26 | ...reactHooks.configs.recommended.rules, 27 | "react-refresh/only-export-components": [ 28 | "warn", 29 | { allowConstantExport: true }, 30 | ], 31 | }, 32 | }, 33 | ); 34 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 16 | AlgoNodeRewards • Cool stats for your Algorand node 17 | 21 | 25 | 26 | 30 | 31 | 32 | 33 |
36 |
37 | 50 |
51 |
52 |
53 |
54 |
55 |

58 | Cool stats for your Algorand staking rewards 59 |

60 |

61 | Get your total node rewards and identify peak performance periods 62 | with our detailed rewards heatmap 63 |

64 |
65 |
66 |
67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "algonoderewards", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Cryptomalgo", 9 | "url": "https://github.com/cryptomalgo", 10 | "social": { 11 | "github": "https://github.com/cryptomalgo", 12 | "x": "https://x.com/cryptomalgo" 13 | } 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/cryptomalgo/algonoderewards" 18 | }, 19 | "homepage": "https://algonoderewards.com", 20 | "bugs": { 21 | "url": "https://github.com/cryptomalgo/algonoderewards/issues" 22 | }, 23 | "scripts": { 24 | "dev": "vite", 25 | "build": "vite build", 26 | "lint": "eslint .", 27 | "preview": "vite preview", 28 | "prettier:fix": "prettier --write .", 29 | "type:check": "tsc -b", 30 | "prettier:check": "prettier --check .", 31 | "test:watch": "vitest", 32 | "test": "vitest --run", 33 | "ci": "npm run lint && npm run prettier:fix && npm run type:check && npm run build && vitest --run" 34 | }, 35 | "dependencies": { 36 | "@algorandfoundation/algokit-utils": "^9.0.0", 37 | "@radix-ui/react-checkbox": "^1.1.4", 38 | "@radix-ui/react-dialog": "^1.1.6", 39 | "@radix-ui/react-dropdown-menu": "^2.1.6", 40 | "@radix-ui/react-label": "^2.1.2", 41 | "@radix-ui/react-popover": "^1.1.6", 42 | "@radix-ui/react-select": "^2.1.6", 43 | "@radix-ui/react-slider": "^1.3.5", 44 | "@radix-ui/react-slot": "^1.2.2", 45 | "@radix-ui/react-toggle": "^1.1.2", 46 | "@radix-ui/react-toggle-group": "^1.1.2", 47 | "@radix-ui/react-tooltip": "^1.1.8", 48 | "@tailwindcss/vite": "^4.0.17", 49 | "@tanstack/react-query": "^5.74.4", 50 | "@tanstack/react-query-devtools": "^5.74.6", 51 | "@tanstack/react-router": "^1.114.29", 52 | "@tanstack/react-router-devtools": "^1.114.29", 53 | "@uiw/react-heat-map": "^2.3.2", 54 | "algosdk": "^3.2.0", 55 | "class-variance-authority": "^0.7.1", 56 | "clsx": "^2.1.1", 57 | "date-fns": "^4.1.0", 58 | "lucide-react": "^0.484.0", 59 | "motion": "^12.6.2", 60 | "next-themes": "^0.4.6", 61 | "react": "^19.0.0", 62 | "react-day-picker": "^9.7", 63 | "react-dom": "^19.0.0", 64 | "react-error-boundary": "^5.0.0", 65 | "react-gauge-component": "^1.2.64", 66 | "react-scan": "^0.3", 67 | "recharts": "^2.15.3", 68 | "sonner": "^2.0.2", 69 | "tailwind-merge": "^3.0.2", 70 | "tailwindcss": "^4.0.17", 71 | "tailwindcss-animate": "^1.0.7", 72 | "vite-tsconfig-paths": "^5.1.4" 73 | }, 74 | "devDependencies": { 75 | "@eslint/js": "^9.23.0", 76 | "@tanstack/router-plugin": "^1.114.29", 77 | "@testing-library/dom": "^10.4.0", 78 | "@testing-library/react": "^16.2.0", 79 | "@types/node": "^22.13.14", 80 | "@types/react": "^19.0.12", 81 | "@types/react-dom": "^19.0.4", 82 | "@vitejs/plugin-react": "^4.3.4", 83 | "autoprefixer": "^10.4.21", 84 | "eslint": "^9.23.0", 85 | "eslint-config-prettier": "^10.1.1", 86 | "eslint-plugin-react-hooks": "^5.2.0", 87 | "eslint-plugin-react-refresh": "^0.4.19", 88 | "globals": "^16.0.0", 89 | "jsdom": "^26.0.0", 90 | "mockdate": "^3.0.5", 91 | "postcss": "^8.5.3", 92 | "prettier": "^3.5.3", 93 | "prettier-plugin-tailwindcss": "^0.6.11", 94 | "timezone-mock": "^1.3.6", 95 | "typescript": "~5.8.2", 96 | "typescript-eslint": "^8.28.0", 97 | "vite": "^6.2.3", 98 | "vitest": "^3.0.9" 99 | }, 100 | "engines": { 101 | "node": ">=22.0.0", 102 | "npm": ">=10.0.0" 103 | }, 104 | "keywords": [ 105 | "algorand", 106 | "algorand node monitoring", 107 | "algorand node rewards", 108 | "algorand node dashboard", 109 | "blockchain" 110 | ] 111 | } 112 | -------------------------------------------------------------------------------- /public/algorand-coin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/algorand-coin.webp -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/favicon-96x96.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/logo.png -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/preview.png -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Algonoderewards", 3 | "short_name": "AlgoNR", 4 | "icons": [ 5 | { 6 | "src": "/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#ffffff", 19 | "background_color": "#ffffff", 20 | "display": "standalone" 21 | } 22 | -------------------------------------------------------------------------------- /public/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /public/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/public/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/screenshot.png -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "tailwindcss-animate"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | @custom-variant dark (&:where(.dark, .dark *)); 6 | 7 | :root { 8 | --background: hsl(0 0% 100%); 9 | --foreground: hsl(240 10% 3.9%); 10 | --card: hsl(0 0% 100%); 11 | --card-foreground: hsl(240 10% 3.9%); 12 | --popover: hsl(0 0% 100%); 13 | --popover-foreground: hsl(240 10% 3.9%); 14 | --tooltip: var(--color-gray-950); 15 | --tooltip-foreground: var(--color-indigo-100); 16 | --primary: hsl(240 5.9% 10%); 17 | --primary-foreground: hsl(0 0% 98%); 18 | --secondary: hsl(240 4.8% 95.9%); 19 | --secondary-foreground: hsl(240 5.9% 10%); 20 | --muted: hsl(240 4.8% 95.9%); 21 | --muted-foreground: hsl(240 3.8% 46.1%); 22 | --accent: hsl(240 4.8% 95.9%); 23 | --accent-foreground: hsl(240 5.9% 10%); 24 | --destructive: hsl(0 84.2% 60.2%); 25 | --destructive-foreground: hsl(0 0% 98%); 26 | --border: hsl(240 5.9% 90%); 27 | --input: hsl(240 5.9% 90%); 28 | --ring: hsl(240 10% 3.9%); 29 | --chart-1: hsl(12 76% 61%); 30 | --chart-2: hsl(173 58% 39%); 31 | --chart-3: hsl(197 37% 24%); 32 | --chart-4: hsl(43 74% 66%); 33 | --chart-5: hsl(27 87% 67%); 34 | --radius: 0.6rem; 35 | } 36 | 37 | .dark { 38 | --background: var(--color-gray-950); 39 | --foreground: hsl(0 0% 98%); 40 | --card: hsl(240 10% 3.9%); 41 | --card-foreground: hsl(0 0% 98%); 42 | --popover: hsl(240 10% 3.9%); 43 | --popover-foreground: hsl(0 0% 98%); 44 | --tooltip: var(--color-slate-600); 45 | --tooltip-foreground: var(--color-indigo-200); 46 | --primary: hsl(0 0% 98%); 47 | --primary-foreground: hsl(240 5.9% 10%); 48 | --secondary: hsl(240 3.7% 15.9%); 49 | --secondary-foreground: hsl(0 0% 98%); 50 | --muted: hsl(240 3.7% 15.9%); 51 | --muted-foreground: hsl(240 5% 64.9%); 52 | --accent: hsl(240 3.7% 15.9%); 53 | --accent-foreground: hsl(0 0% 98%); 54 | --destructive: hsl(0 62.8% 30.6%); 55 | --destructive-foreground: hsl(0 0% 98%); 56 | --border: hsl(240 3.7% 15.9%); 57 | --input: hsl(240 3.7% 15.9%); 58 | --ring: hsl(240 4.9% 83.9%); 59 | --chart-1: hsl(220 70% 50%); 60 | --chart-2: hsl(160 60% 45%); 61 | --chart-3: hsl(30 80% 55%); 62 | --chart-4: hsl(280 65% 60%); 63 | --chart-5: hsl(340 75% 55%); 64 | } 65 | 66 | @theme inline { 67 | --color-background: var(--background); 68 | --color-foreground: var(--foreground); 69 | --color-card: var(--card); 70 | --color-card-foreground: var(--card-foreground); 71 | --color-popover: var(--popover); 72 | --color-popover-foreground: var(--popover-foreground); 73 | --color-tooltip: var(--tooltip); 74 | --color-tooltip-foreground: var(--tooltip-foreground); 75 | --color-primary: var(--primary); 76 | --color-primary-foreground: var(--primary-foreground); 77 | --color-secondary: var(--secondary); 78 | --color-secondary-foreground: var(--secondary-foreground); 79 | --color-muted: var(--muted); 80 | --color-muted-foreground: var(--muted-foreground); 81 | --color-accent: var(--accent); 82 | --color-accent-foreground: var(--accent-foreground); 83 | --color-destructive: var(--destructive); 84 | --color-destructive-foreground: var(--destructive-foreground); 85 | --color-border: var(--border); 86 | --color-input: var(--input); 87 | --color-ring: var(--ring); 88 | --color-chart-1: var(--chart-1); 89 | --color-chart-2: var(--chart-2); 90 | --color-chart-3: var(--chart-3); 91 | --color-chart-4: var(--chart-4); 92 | --color-chart-5: var(--chart-5); 93 | --radius-sm: calc(var(--radius) - 4px); 94 | --radius-md: calc(var(--radius) - 2px); 95 | --radius-lg: var(--radius); 96 | --radius-xl: calc(var(--radius) + 4px); 97 | } 98 | 99 | @layer base { 100 | * { 101 | @apply border-border outline-ring/50; 102 | } 103 | body { 104 | @apply bg-background text-foreground; 105 | } 106 | } 107 | 108 | @theme { 109 | --font-sans: InterVariable, sans-serif; 110 | } 111 | 112 | @theme { 113 | --animate-coin-spread: coin-spread 1.5s ease-out forwards; 114 | 115 | @keyframes coin-spread { 116 | 0% { 117 | opacity: 1; 118 | transform: translate(-50%, -50%) scale(0); 119 | } 120 | 50% { 121 | opacity: 0.5; 122 | } 123 | 100% { 124 | opacity: 0; 125 | transform: translate(-50%, calc(-50% - 100px)) scale(1); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/address-input.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cryptomalgo/algonoderewards/8e9cef07b672666023fa49ca5942cfe9fffc39af/src/components/address-input.tsx -------------------------------------------------------------------------------- /src/components/address/add-address.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from "framer-motion"; 2 | import SearchBar from "./search-bar"; 3 | import { ResolvedAddress } from "@/components/heatmap/types.ts"; 4 | 5 | export default function AddAddress({ 6 | showAddAddress, 7 | resolvedAddresses, 8 | setAddresses, 9 | }: { 10 | showAddAddress: boolean; 11 | resolvedAddresses: ResolvedAddress[]; 12 | setAddresses: (addresses: string[]) => void; 13 | }) { 14 | return ( 15 | 16 | {showAddAddress && ( 17 | 24 | 27 | resolvedAddress.nfd ?? resolvedAddress.address, 28 | )} 29 | setAddresses={setAddresses} 30 | /> 31 | 32 | )} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/address/address-breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ChevronRightIcon, 3 | FilterIcon, 4 | HomeIcon, 5 | SlidersHorizontalIcon, 6 | } from "lucide-react"; 7 | import { ResolvedAddress } from "@/components/heatmap/types"; 8 | import { cn, displayAlgoAddress } from "@/lib/utils"; 9 | import Spinner from "@/components/spinner.tsx"; 10 | import { 11 | Tooltip, 12 | TooltipContent, 13 | TooltipProvider, 14 | TooltipTrigger, 15 | } from "@/components/ui/tooltip.tsx"; 16 | import Settings from "./settings.tsx"; 17 | import { useTheme } from "@/components/theme-provider"; 18 | import { Block } from "algosdk/client/indexer"; 19 | 20 | export default function AddressBreadcrumb({ 21 | resolvedAddresses, 22 | showFilters, 23 | setShowFilters, 24 | showAddAddress, 25 | setShowAddAddress, 26 | blocks, 27 | }: { 28 | resolvedAddresses: ResolvedAddress[]; 29 | showFilters: boolean; 30 | setShowFilters: (show: boolean) => void; 31 | showAddAddress: boolean; 32 | setShowAddAddress: (show: boolean) => void; 33 | blocks: Block[]; 34 | }) { 35 | const { theme } = useTheme(); 36 | return ( 37 | 138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /src/components/address/address-filters.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from "framer-motion"; 2 | import { CheckIcon } from "lucide-react"; 3 | import { displayAlgoAddress } from "@/lib/utils"; 4 | import CopyButton from "@/components/copy-to-clipboard"; 5 | import { ResolvedAddress } from "@/components/heatmap/types"; 6 | import { useAccount } from "@/hooks/useAccounts"; 7 | import AccountStatus from "./stats/status/status"; 8 | 9 | export default function AddressFilters({ 10 | showFilters, 11 | resolvedAddresses, 12 | selectedAddresses, 13 | setSelectedAddresses, 14 | }: { 15 | showFilters: boolean; 16 | resolvedAddresses: ResolvedAddress[]; 17 | selectedAddresses: string[]; 18 | setSelectedAddresses: (addresses: string[]) => void; 19 | }) { 20 | const selectAllAddresses = () => { 21 | if (selectedAddresses.length === resolvedAddresses.length) { 22 | setSelectedAddresses([]); 23 | return; 24 | } 25 | setSelectedAddresses(resolvedAddresses.map((addr) => addr.address)); 26 | }; 27 | 28 | const handleAddressToggle = (address: string, isChecked: boolean) => { 29 | if (isChecked) { 30 | setSelectedAddresses([...selectedAddresses, address]); 31 | return; 32 | } 33 | setSelectedAddresses(selectedAddresses.filter((addr) => addr !== address)); 34 | }; 35 | 36 | if (resolvedAddresses.length <= 1) return null; 37 | 38 | return ( 39 | 40 | {showFilters && ( 41 | 48 |
52 | Accounts list 53 |
54 |
55 |
56 | 65 | 72 |
73 |
74 |
75 | 83 |
84 |
85 | {resolvedAddresses.map((address) => ( 86 | 91 | handleAddressToggle(address.address, checked) 92 | } 93 | /> 94 | ))} 95 |
96 |
97 | )} 98 |
99 | ); 100 | } 101 | 102 | function AddressCheckbox({ 103 | address, 104 | checked, 105 | onChange, 106 | }: { 107 | address: ResolvedAddress; 108 | checked: boolean; 109 | onChange: (checked: boolean) => void; 110 | }) { 111 | const { data: account } = useAccount(address); 112 | 113 | return ( 114 |
115 |
116 |
117 | onChange(e.target.checked)} 122 | className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:checked:border-indigo-500 dark:checked:bg-indigo-600 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-600 dark:focus-visible:outline-indigo-500 dark:disabled:border-gray-700 dark:disabled:bg-gray-800 forced-colors:appearance-auto" 123 | /> 124 | 129 |
130 |
131 |
132 | 142 |

143 | {address.address} 144 |

145 | {account && } 146 |
147 |
148 | ); 149 | } 150 | -------------------------------------------------------------------------------- /src/components/address/address-view.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import { useBlocks } from "@/hooks/useRewardTransactions"; 3 | import { useAlgorandAddresses } from "@/hooks/useAlgorandAddress"; 4 | import { Error } from "@/components/error"; 5 | import Heatmap from "@/components/heatmap/heatmap"; 6 | import AddressBreadcrumb from "./address-breadcrumb"; 7 | import AddressFilters from "./address-filters"; 8 | import StatsPanels from "./stats/stats-panels"; 9 | import AddAddress from "./add-address"; 10 | import { useNavigate } from "@tanstack/react-router"; 11 | import CopyButton from "@/components/copy-to-clipboard.tsx"; 12 | import { displayAlgoAddress } from "@/lib/utils.ts"; 13 | import CumulativeRewardsChart from "@/components/address/charts/cumulative-rewards-chart"; 14 | import CumulativeBlocksChart from "@/components/address/charts/cumulative-blocks-chart"; 15 | import RewardByDayHourChart from "@/components/address/charts/reward-by-day-hour-chart.tsx"; 16 | import AccountStatus from "./stats/status/status"; 17 | import BlockRewardIntervals from "./charts/block-reward-intervals"; 18 | 19 | export default function AddressView({ addresses }: { addresses: string }) { 20 | const navigate = useNavigate(); 21 | const [showFilters, setShowFilters] = useState(false); 22 | const [showAddAddress, setShowAddAddress] = useState(false); 23 | const addressesArray = useMemo( 24 | () => addresses.split(",").filter(Boolean), 25 | [addresses], 26 | ); 27 | const { resolvedAddresses } = useAlgorandAddresses(addressesArray); 28 | 29 | // Function to update addresses in both state and URL 30 | const handleAddAddresses = (newAddresses: string[]) => { 31 | // Create a unique set of addresses (including only non-empty values) 32 | const uniqueAddresses = [...new Set([...newAddresses])].filter(Boolean); 33 | 34 | // Update the URL without triggering a full navigation 35 | navigate({ 36 | to: "/$addresses", 37 | params: { addresses: uniqueAddresses.join(",") }, 38 | replace: true, 39 | search: (prev) => ({ 40 | hideBalance: false, 41 | theme: prev.theme ?? "system", 42 | statsPanelTheme: prev.statsPanelTheme ?? "indigo", 43 | }), 44 | }); 45 | }; 46 | // Track selected addresses with a state 47 | const [selectedAddresses, setSelectedAddresses] = useState([]); 48 | 49 | // Set all addresses as selected when resolvedAddresses changes 50 | useMemo(() => { 51 | if (resolvedAddresses?.length > 0) { 52 | setSelectedAddresses(resolvedAddresses.map((addr) => addr.address)); 53 | } 54 | }, [resolvedAddresses]); 55 | 56 | const { data: blocks, loading, hasError } = useBlocks(resolvedAddresses); 57 | 58 | // Filter blocks based on selected addresses 59 | const filteredBlocks = useMemo(() => { 60 | if (!blocks) return []; 61 | if (selectedAddresses.length === 0) return []; 62 | 63 | return blocks.filter( 64 | (block) => 65 | block.proposer && selectedAddresses.includes(block.proposer.toString()), 66 | ); 67 | }, [blocks, selectedAddresses]); 68 | 69 | if (hasError) { 70 | return ; 71 | } 72 | 73 | return ( 74 |
75 | 76 | 77 |
78 |
79 |
80 | 88 | 93 | 99 | {resolvedAddresses.length === 1 && !showAddAddress && ( 100 |
101 |
102 |

103 | {displayAlgoAddress(resolvedAddresses[0].address)} 104 |

105 |

106 | {resolvedAddresses[0].address} 107 |

108 | 109 |
110 | 111 |
112 | )} 113 |
114 | 115 |
116 | 121 | 122 | 123 | 124 | 128 | 129 |
130 |
131 |
132 |
133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /src/components/address/charts/reward-by-day-hour-chart.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { 3 | ScatterChart, 4 | Scatter, 5 | XAxis, 6 | YAxis, 7 | ZAxis, 8 | Tooltip, 9 | ResponsiveContainer, 10 | Cell, 11 | } from "recharts"; 12 | import { Block } from "algosdk/client/indexer"; 13 | import { useTheme } from "@/components/theme-provider"; 14 | 15 | const formatHourRange = (hour: number) => { 16 | // Format start time 17 | const startHour = hour % 12 || 12; // Convert 0 to 12 for 12AM 18 | const startAmPm = hour < 12 ? "AM" : "PM"; 19 | 20 | // Format end time (next hour) 21 | const endHour = (hour + 1) % 12 || 12; 22 | const endAmPm = hour + 1 < 12 || hour + 1 === 24 ? "AM" : "PM"; 23 | 24 | return `from ${startHour}${startAmPm} to ${endHour}${endAmPm}`; 25 | }; 26 | 27 | interface RewardByDayHourChartProps { 28 | blocks: Block[]; 29 | } 30 | 31 | type DayHourData = { 32 | x: number; // hour (0-23) 33 | y: number; // day (0-6, 0 is Monday) 34 | z: number; // count of blocks 35 | day: string; // day name 36 | }; 37 | 38 | // Days starting with Monday 39 | const DAYS = [ 40 | "Monday", 41 | "Tuesday", 42 | "Wednesday", 43 | "Thursday", 44 | "Friday", 45 | "Saturday", 46 | "Sunday", 47 | ]; 48 | 49 | export default function RewardByDayHourChart({ 50 | blocks, 51 | }: RewardByDayHourChartProps) { 52 | const { theme } = useTheme(); 53 | const isDarkMode = theme === "dark"; 54 | 55 | const dayHourData = useMemo(() => { 56 | if (!blocks?.length) return []; 57 | 58 | // Initialize data structure for all day/hour combinations 59 | const dayHourMap: Record = {}; 60 | 61 | // Create entries for all possible day/hour combinations 62 | for (let day = 0; day < 7; day++) { 63 | for (let hour = 0; hour < 24; hour++) { 64 | const key = `${day}-${hour}`; 65 | dayHourMap[key] = { 66 | x: hour, 67 | y: day, 68 | z: 0, 69 | day: DAYS[day], 70 | }; 71 | } 72 | } 73 | 74 | // Count blocks for each day/hour combination 75 | blocks.forEach((block) => { 76 | // Use local time, not UTC 77 | const date = new Date(block.timestamp * 1000); 78 | // Convert Sunday=0 to Monday=0 format 79 | let day = date.getDay() - 1; // Adjust to make Monday=0 80 | if (day === -1) day = 6; // Sunday becomes 6 81 | 82 | const hour = date.getHours(); // 0-23 83 | 84 | const key = `${day}-${hour}`; 85 | if (dayHourMap[key]) { 86 | dayHourMap[key].z += 1; 87 | } 88 | }); 89 | 90 | // Convert map to array, filtering out entries with no blocks 91 | return Object.values(dayHourMap).filter((entry) => entry.z > 0); 92 | }, [blocks]); 93 | 94 | if (!blocks?.length) { 95 | return ( 96 |
97 | No data available 98 |
99 | ); 100 | } 101 | 102 | const maxCount = Math.max(...dayHourData.map((d) => d.z)); 103 | 104 | // Get bubble color based on count and theme 105 | const getBubbleColor = (count: number) => { 106 | const ratio = count / maxCount; 107 | 108 | // Light mode colors 109 | const lightModeStart = { r: 240, g: 240, b: 255 }; // Faded indigo (almost white) 110 | const lightModeEnd = { r: 79, g: 70, b: 229 }; // Dark indigo 111 | 112 | // Dark mode colors - deeper blue that looks good in dark mode 113 | const darkModeStart = { r: 30, g: 41, b: 59 }; // Faded indigo dark 114 | const darkModeEnd = { r: 97, g: 95, b: 255 }; // Bright indigo 115 | 116 | const start = isDarkMode ? darkModeStart : lightModeStart; 117 | const end = isDarkMode ? darkModeEnd : lightModeEnd; 118 | 119 | const r = Math.round(start.r + (end.r - start.r) * ratio); 120 | const g = Math.round(start.g + (end.g - start.g) * ratio); 121 | const b = Math.round(start.b + (end.b - start.b) * ratio); 122 | 123 | return `rgb(${r}, ${g}, ${b})`; 124 | }; 125 | 126 | return ( 127 |
128 |

129 | Block Distribution by Day and Hour 130 |

131 |
132 | 133 | 141 | `${hour}h`} 150 | padding={{ left: 10, right: 10 }} 151 | /> 152 | DAYS[day].slice(0, 3)} 161 | reversed={true} // Reverse the Y-axis to put Monday (0) at the top 162 | padding={{ top: 10, bottom: 10 }} 163 | /> 164 | 165 | { 168 | const { active, payload } = props; 169 | 170 | if (active && payload && payload.length) { 171 | const data = payload[0] && payload[0].payload; 172 | 173 | return ( 174 |
179 |

180 | {data.z} blocks proposed on {DAYS[data.y]}s{" "} 181 | {formatHourRange(data.x)}{" "} 182 |

183 |
184 | ); 185 | } 186 | 187 | return null; 188 | }} 189 | /> 190 | 191 | {dayHourData.map((entry, index) => ( 192 | 193 | ))} 194 | 195 |
196 |
197 |
198 |

199 | Bubble size and color intensity represent the number of blocks proposed 200 |

201 |
202 | ); 203 | } 204 | -------------------------------------------------------------------------------- /src/components/address/search-bar.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import AlgorandLogo from "@/components/algorand-logo.tsx"; 3 | import { displayAlgoAddress } from "@/lib/utils.ts"; 4 | import { XIcon } from "lucide-react"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | export default function SearchBar({ 8 | addresses, 9 | setAddresses, 10 | }: { 11 | addresses: string[]; 12 | setAddresses: (addresses: string[]) => void; 13 | }) { 14 | const [inputValue, setInputValue] = useState(""); 15 | const [isInputValid, setIsInputValid] = useState(true); 16 | 17 | const validateAddress = (address: string): boolean => { 18 | const trimmedAddress = address.trim(); 19 | // Check if it's a 58-char alphanumeric string or ends with .algo (case insensitive) 20 | return ( 21 | /^[A-Za-z0-9]{58}$/.test(trimmedAddress) || 22 | /\.algo$/i.test(trimmedAddress) 23 | ); 24 | }; 25 | 26 | const addAddress = () => { 27 | const trimmedInput = inputValue.trim(); 28 | 29 | if (trimmedInput === "") return; 30 | 31 | if (!validateAddress(trimmedInput)) { 32 | setIsInputValid(false); 33 | return; 34 | } 35 | 36 | if (!addresses.includes(trimmedInput)) { 37 | setAddresses([...addresses, trimmedInput]); 38 | setInputValue(""); 39 | setIsInputValid(true); 40 | } 41 | }; 42 | 43 | const handleInputChange = (e: React.ChangeEvent) => { 44 | setInputValue(e.target.value); 45 | setIsInputValid(true); // Reset validation state when input changes 46 | }; 47 | 48 | const removeAddress = (indexToRemove: number) => { 49 | setAddresses(addresses.filter((_, index) => index !== indexToRemove)); 50 | }; 51 | 52 | const handleKeyDown = (e: React.KeyboardEvent) => { 53 | if (e.key === "Enter") { 54 | e.preventDefault(); // Prevent form submission 55 | addAddress(); 56 | } 57 | }; 58 | 59 | return ( 60 |
61 | 68 | 69 | {/* Address tags */} 70 |
71 | {addresses.length > 0 && 72 | addresses.map((address, index) => ( 73 |
77 | 78 | {address.length === 58 ? displayAlgoAddress(address) : address} 79 | 80 | {addresses.length > 1 && ( 81 | 88 | )} 89 |
90 | ))} 91 |
92 | 93 |
94 |
95 | 116 | 117 | 118 |
119 | 120 | 133 |
134 | {!isInputValid && ( 135 |

136 | Please enter a valid Algorand address (58 characters) or NFD domain 137 | (.algo) 138 |

139 | )} 140 |
141 | ); 142 | } 143 | -------------------------------------------------------------------------------- /src/components/address/stats/panel.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { useSearch } from "@tanstack/react-router"; 3 | 4 | export function Panel({ children }: { children: React.ReactNode }) { 5 | const search = useSearch({ from: "/$addresses" }); 6 | const statsPanelTheme = search.statsPanelTheme; 7 | 8 | return ( 9 |
15 |
{children}
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/address/stats/panels/apy-panel.tsx: -------------------------------------------------------------------------------- 1 | import { ResolvedAddress } from "@/components/heatmap/types"; 2 | import { useAccounts } from "@/hooks/useAccounts"; 3 | import { BlockStats } from "@/hooks/useBlocksStats"; 4 | import { useSearch } from "@tanstack/react-router"; 5 | import StatBox from "../stat-box"; 6 | import { calculateAPYAndProjection } from "@/lib/utils"; 7 | import { 8 | Tooltip, 9 | TooltipContent, 10 | TooltipProvider, 11 | TooltipTrigger, 12 | } from "@/components/ui/mobile-tooltip"; 13 | import NumberDisplay from "@/components/number-display"; 14 | import AlgoAmountDisplay from "@/components/algo-amount-display"; 15 | import { Panel } from "../panel"; 16 | 17 | function ApyDisplay({ 18 | totalRewardsOverPeriod, 19 | amountStacked, 20 | nbDays, 21 | hidden, 22 | }: { 23 | totalRewardsOverPeriod: number; 24 | amountStacked: number; 25 | nbDays: number; 26 | hidden: boolean; 27 | }) { 28 | const apy = calculateAPYAndProjection( 29 | totalRewardsOverPeriod, 30 | amountStacked, 31 | nbDays, 32 | ); 33 | 34 | return ( 35 | 36 | 37 | 38 |
39 | 40 | % 41 | 42 |
49 |
50 | 51 | 56 | {` rewarded over ${nbDays} days with `} 57 | {" "} 62 | stacked.
63 | APY is calculated as:{" "} 64 | 65 | (rewards / algoStaked) * (365 / nbDays) * 100) 66 | 67 |
68 |
69 |
70 | ); 71 | } 72 | 73 | export function ApyPanel({ 74 | stats, 75 | loading, 76 | resolvedAddresses, 77 | }: { 78 | stats: BlockStats; 79 | loading: boolean; 80 | resolvedAddresses: ResolvedAddress[]; 81 | }) { 82 | const search = useSearch({ from: "/$addresses" }); 83 | const isAmountHidden = search.hideBalance; 84 | 85 | const { data, pending } = useAccounts(resolvedAddresses); 86 | 87 | let totalAmount = 0n; 88 | if (!pending && data && data.length > 0) { 89 | for (const account of data) { 90 | if (account && account.amount) { 91 | totalAmount += BigInt(account.amount); 92 | } 93 | } 94 | } 95 | 96 | return ( 97 | 98 |
99 |
136 |
137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /src/components/address/stats/panels/blocks-per-day-panel.tsx: -------------------------------------------------------------------------------- 1 | import { BlockStats } from "@/hooks/useBlocksStats"; 2 | import { Panel } from "../panel"; 3 | import StatBox from "../stat-box"; 4 | import { 5 | Tooltip, 6 | TooltipContent, 7 | TooltipProvider, 8 | TooltipTrigger, 9 | } from "@/components/ui/mobile-tooltip"; 10 | import NumberDisplay from "@/components/number-display"; 11 | import PercentageChange from "../percentage-change"; 12 | import { AVERAGE_DAY_IN_MONTH } from "@/constants"; 13 | 14 | function PreviousBlocksTooltip({ 15 | total, 16 | average, 17 | startDate, 18 | endDate, 19 | }: { 20 | total: number; 21 | average: number; 22 | startDate: Date; 23 | endDate: Date; 24 | }) { 25 | return ( 26 | 27 | {total} blocks proposed from {startDate.toLocaleDateString()} to{" "} 28 | {endDate.toLocaleDateString()}, so {average} per day in average 29 | 30 | ); 31 | } 32 | 33 | export function BlocksPerDayPanel({ 34 | stats, 35 | loading, 36 | }: { 37 | stats: BlockStats; 38 | loading: boolean; 39 | }) { 40 | return ( 41 | 42 |
43 | 48 | 49 | 50 | 51 | 52 | 53 | {stats.allTime.startDate !== null && 54 | stats.allTime.endDate && 55 | `${stats.allTime.totalBlocks} blocks rewarded in ${stats.allTime.totalDays} days (from ${stats.allTime.startDate.toLocaleDateString()} to ${stats.allTime.endDate.toLocaleDateString()})`} 56 | 57 | 58 | 59 | } 60 | /> 61 | 66 | 67 | 68 | 71 | 72 | 73 | {stats.allTime.startDate !== null && 74 | stats.allTime.endDate && 75 | `${stats.allTime.avgBlocksPerDay.toFixed(3)} blocks per day x ${AVERAGE_DAY_IN_MONTH} days in a month (365/12)`} 76 | 77 | 78 | 79 | } 80 | /> 81 | 82 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | {`${stats.last30Days.totalBlocks} blocks proposed from ${stats.last30Days.startDate.toLocaleString()} to ${stats.last30Days.endDate.toLocaleString()}`} 94 | 95 | 96 | 97 | 107 | } 108 | /> 109 |
110 | } 111 | /> 112 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | {`${stats.last7Days.totalBlocks} blocks proposed from ${stats.last7Days.startDate.toLocaleDateString()} to ${stats.last7Days.endDate.toLocaleDateString()}`} 124 | 125 | 126 | 127 | 137 | } 138 | /> 139 | 140 | } 141 | /> 142 | 143 |
144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /src/components/address/stats/panels/total-panel.tsx: -------------------------------------------------------------------------------- 1 | import AlgoAmountDisplay from "@/components/algo-amount-display"; 2 | import NumberDisplay from "@/components/number-display"; 3 | import { 4 | TooltipProvider, 5 | Tooltip, 6 | TooltipTrigger, 7 | TooltipContent, 8 | } from "@/components/ui/mobile-tooltip"; 9 | import { BlockStats } from "@/hooks/useBlocksStats"; 10 | import StatBox from "../stat-box"; 11 | import { Panel } from "../panel"; 12 | 13 | export function TotalsPanel({ 14 | stats, 15 | loading, 16 | }: { 17 | stats: BlockStats; 18 | loading: boolean; 19 | }) { 20 | return ( 21 | 22 |
23 | } 27 | /> 28 | } 32 | /> 33 | 38 | 39 | 40 | 41 | 42 | 43 | {stats.maxBlocksInDayDateString} 44 | 45 | 46 | 47 | } 48 | /> 49 | 54 | 55 | 56 | 57 | 58 | 59 | {stats.maxRewardsInDayDateString} 60 | 61 | 62 | 63 | } 64 | /> 65 | 70 | 71 | 72 | 73 | 74 | {stats.minRewardDate} 75 | 76 | 77 | } 78 | /> 79 | 84 | 85 | 86 | 87 | 88 | {stats.maxRewardDate} 89 | 90 | 91 | } 92 | /> 93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/components/address/stats/percentage-change.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Tooltip, 3 | TooltipContent, 4 | TooltipProvider, 5 | TooltipTrigger, 6 | } from "@/components/ui/mobile-tooltip"; 7 | import { cn } from "@/lib/utils"; 8 | import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; 9 | 10 | export default function PercentageChange({ 11 | percentage, 12 | direction, 13 | previousValueDisplay, 14 | className, 15 | }: { 16 | percentage: number; 17 | direction: "up" | "down" | "none"; 18 | previousValueDisplay: React.ReactNode; 19 | className?: string; 20 | }) { 21 | if (direction === "none") return null; 22 | 23 | const Icon = direction === "up" ? ArrowUpIcon : ArrowDownIcon; 24 | return ( 25 | 26 | 27 | 28 | 36 | 44 | {percentage}% 45 | 46 | 47 | {previousValueDisplay} 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/address/stats/stat-box.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorBoundary } from "react-error-boundary"; 2 | import { AlertCircleIcon } from "lucide-react"; 3 | import Spinner from "@/components/spinner.tsx"; 4 | import { useSearch } from "@tanstack/react-router"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | export default function StatBox({ 8 | title, 9 | content, 10 | loading, 11 | }: { 12 | title: string; 13 | content: React.ReactNode; 14 | loading: boolean; 15 | }) { 16 | const search = useSearch({ from: "/$addresses" }); 17 | const statsPanelTheme = search.statsPanelTheme; 18 | return ( 19 |
27 |

{title}

28 |
29 | 35 | 38 | 39 | Error loading stat 40 |
41 | } 42 | > 43 | {loading ? : content} 44 | 45 | 46 |
47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/components/address/stats/stats-panels.tsx: -------------------------------------------------------------------------------- 1 | import { Block } from "algosdk/client/indexer"; 2 | import { useBlocksStats } from "@/hooks/useBlocksStats"; 3 | import { ResolvedAddress } from "@/components/heatmap/types"; 4 | import { BlocksPerDayPanel } from "./panels/blocks-per-day-panel"; 5 | import { RewardsPerDayPanel } from "./panels/rewards-per-day-panel"; 6 | import { ApyPanel } from "./panels/apy-panel"; 7 | import { TotalsPanel } from "./panels/total-panel"; 8 | 9 | export default function StatsPanels({ 10 | filteredBlocks, 11 | loading, 12 | resolvedAddresses, 13 | }: { 14 | filteredBlocks: Block[]; 15 | loading: boolean; 16 | resolvedAddresses: ResolvedAddress[]; 17 | }) { 18 | const stats = useBlocksStats(filteredBlocks); 19 | 20 | return ( 21 |
22 | 27 | 28 |
29 | 30 | 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/address/stats/status/anticipated-time-between-blocks-badge.tsx: -------------------------------------------------------------------------------- 1 | import { formatMinutes } from "@/lib/utils"; 2 | import { ClockFadingIcon } from "lucide-react"; 3 | 4 | export default function AnticipatedTimeBetweenBlocksBadge({ 5 | ancitipatedTimeInMinutes, 6 | hidden, 7 | }: { 8 | ancitipatedTimeInMinutes: number; 9 | hidden: boolean; 10 | }) { 11 | return ( 12 |
13 | 14 | 15 | Estimated block interval:{" "} 16 | {hidden ? "***" : formatMinutes(Math.round(ancitipatedTimeInMinutes))} 17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/address/stats/status/anxiety-box.tsx: -------------------------------------------------------------------------------- 1 | import { DotBadge } from "@/components/dot-badge"; 2 | import { useAverageBlockTime } from "@/hooks/useAverageBlockTime"; 3 | import { useStakeInfo } from "@/hooks/useStakeInfo"; 4 | import { formatMinutes } from "@/lib/utils"; 5 | import { AlgoAmount } from "@algorandfoundation/algokit-utils/types/amount"; 6 | import { Account } from "algosdk/client/indexer"; 7 | import { AnxietyGauge } from "./anxiety-gauge"; 8 | import { LastBlockProposedBadge } from "./last-block-proposed-badge"; 9 | import AnticipatedTimeBetweenBlocksBadge from "./anticipated-time-between-blocks-badge"; 10 | import { 11 | TooltipProvider, 12 | Tooltip, 13 | TooltipContent, 14 | TooltipTrigger, 15 | } from "@/components/ui/mobile-tooltip"; 16 | import AlgoAmountDisplay from "@/components/algo-amount-display"; 17 | import Spinner from "@/components/spinner"; 18 | import { useSearch } from "@tanstack/react-router"; 19 | 20 | /** 21 | * Calculate the likelihood of not receiving rewards based on stake and rounds since last reward 22 | * @param accountStake The stake amount for the account 23 | * @param totalOnlineStake The total online stake in the network 24 | * @param roundsSinceLastReward Number of rounds since the account last proposed a block 25 | * @returns Probability (0-100) that the account should have proposed a block by now 26 | */ 27 | function calculateLikelihoodOfNoRewards( 28 | accountStake: AlgoAmount, 29 | totalOnlineStake: AlgoAmount, 30 | roundsSinceLastReward: number, 31 | ): number { 32 | const percentageOfTotalStake = accountStake.algos / totalOnlineStake.algos; 33 | 34 | return (1 - percentageOfTotalStake) ** roundsSinceLastReward * 100; 35 | } 36 | 37 | export function AnxietyBox({ account }: { account: Account }) { 38 | const search = useSearch({ from: "/$addresses" }); 39 | 40 | const isBalanceHidden = search.hideBalance; 41 | 42 | const { 43 | data: stakeInfo, 44 | isPending: isStakeInfoPending, 45 | error: stakeInfoError, 46 | } = useStakeInfo(); 47 | const { 48 | data: averageBlockTime, 49 | isPending: isBlockTimePending, 50 | error: blockTimeError, 51 | } = useAverageBlockTime(); 52 | 53 | if (isStakeInfoPending || isBlockTimePending) { 54 | return ; 55 | } 56 | if (stakeInfoError) { 57 | return ( 58 |
59 | Error fetching stake info: {stakeInfoError.message} 60 |
61 | ); 62 | } 63 | 64 | if (blockTimeError) { 65 | return ( 66 |
67 | Error fetching stake info: {blockTimeError.message} 68 |
69 | ); 70 | } 71 | 72 | if (account.lastProposed === undefined) { 73 | return ( 74 | 75 | ); 76 | } 77 | 78 | const accountStake = new AlgoAmount({ microAlgos: account.amount }); 79 | const totalStake = new AlgoAmount({ 80 | microAlgos: Number(stakeInfo.stake_micro_algo), 81 | }); 82 | 83 | const stakeRatio = 84 | Number(accountStake.microAlgos) / Number(totalStake.microAlgos); 85 | const roundsBetweenBlocks = 86 | Number(totalStake.microAlgos) / Number(accountStake.microAlgos); 87 | const anticipatedBlockTimeMinutes = averageBlockTime / stakeRatio / 60; 88 | const likelihoodOfNoRewards = calculateLikelihoodOfNoRewards( 89 | accountStake, 90 | totalStake, 91 | Number(account.round) - account.lastProposed, 92 | ); 93 | 94 | return ( 95 |
96 |
97 | 98 | 99 | 100 | 101 | 102 | 103 | Shows how normal it is to not have proposed a block since the last 104 | one, based on expected timing between proposed blocks. 105 | 106 | 107 | 108 |
109 |
146 | ); 147 | } 148 | -------------------------------------------------------------------------------- /src/components/address/stats/status/anxiety-card.tsx: -------------------------------------------------------------------------------- 1 | import { Account } from "algosdk/client/indexer"; 2 | import { CloverIcon } from "lucide-react"; 3 | import { AnxietyBox } from "./anxiety-box"; 4 | import { BLOCK_ANXIETY_BLOG_POST_URL } from "@/constants"; 5 | 6 | export function AnxietyCard({ account }: { account: Account }) { 7 | return ( 8 |
9 |
10 |
11 | 12 | 13 | No block probability 14 | 15 | 16 | 21 | Learn more 22 | 23 |
24 | 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/address/stats/status/anxiety-gauge.tsx: -------------------------------------------------------------------------------- 1 | import GaugeComponent from "react-gauge-component"; 2 | 3 | export function AnxietyGauge({ value }: { value: number }) { 4 | return ( 5 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/address/stats/status/balance-badge.tsx: -------------------------------------------------------------------------------- 1 | import { DotBadge } from "@/components/dot-badge"; 2 | import { ALGO_BALANCE_THRESHOLD_FOR_REWARDS } from "@/constants"; 3 | import { AlgoAmount } from "@algorandfoundation/algokit-utils/types/amount"; 4 | import { Account } from "algosdk/client/indexer"; 5 | 6 | export function BalanceThresholdBadge({ account }: { account: Account }) { 7 | const isBalanceOverThreshold = 8 | new AlgoAmount({ microAlgos: account.amount }) >= 9 | new AlgoAmount({ algos: ALGO_BALANCE_THRESHOLD_FOR_REWARDS }); 10 | 11 | if (isBalanceOverThreshold) { 12 | return ( 13 | 18 | ); 19 | } 20 | return ( 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/address/stats/status/balance-card.tsx: -------------------------------------------------------------------------------- 1 | import AlgoAmountDisplay from "@/components/algo-amount-display"; 2 | import { Button } from "@/components/ui/button"; 3 | import { useSearch, useNavigate } from "@tanstack/react-router"; 4 | import { Account } from "algosdk/client/indexer"; 5 | import { CoinsIcon, EyeOffIcon, EyeIcon } from "lucide-react"; 6 | 7 | export function BalanceCard({ account }: { account: Account }) { 8 | // Read from search params instead of local state 9 | const search = useSearch({ from: "/$addresses" }); 10 | const navigate = useNavigate({ from: "/$addresses" }); 11 | 12 | const isBalanceHidden = search.hideBalance; 13 | 14 | // Toggle handler that updates the URL 15 | const toggleBalanceVisibility = () => { 16 | navigate({ 17 | search: (prev) => ({ 18 | ...prev, 19 | hideBalance: !isBalanceHidden, 20 | }), 21 | replace: true, // Replace the URL to avoid adding to history stack 22 | }); 23 | }; 24 | 25 | return ( 26 |
27 |
28 | 29 | 30 | Staked 31 | 32 | 45 |
46 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/address/stats/status/incentive-eligibility-badge.tsx: -------------------------------------------------------------------------------- 1 | import { DotBadge } from "@/components/dot-badge"; 2 | import { Account } from "algosdk/client/indexer"; 3 | 4 | export function IncentiveEligibilityBadge({ account }: { account: Account }) { 5 | if (account.incentiveEligible) { 6 | return ( 7 | 8 | ); 9 | } 10 | return ( 11 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/address/stats/status/last-block-proposed-badge.tsx: -------------------------------------------------------------------------------- 1 | import { DotBadge } from "@/components/dot-badge"; 2 | import { ExplorerLink } from "@/components/explorer-link"; 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipProvider, 7 | TooltipTrigger, 8 | } from "@/components/ui/mobile-tooltip"; 9 | import { useBlock } from "@/hooks/useBlock"; 10 | import { Account } from "algosdk/client/indexer"; 11 | import { format, formatDistanceToNow } from "date-fns"; 12 | import { BoxIcon } from "lucide-react"; 13 | import React from "react"; 14 | 15 | export function LastBlockProposedBadge({ 16 | account, 17 | hidden, 18 | }: { 19 | account: Account; 20 | hidden: boolean; 21 | }) { 22 | const { data: block } = useBlock(account.lastProposed); 23 | // Add a state to trigger re-renders 24 | const [, setForceUpdate] = React.useState(0); 25 | 26 | // Set up an interval to update the component every minute 27 | React.useEffect(() => { 28 | const intervalId = setInterval(() => { 29 | setForceUpdate((prev) => prev + 1); // Increment to trigger re-render 30 | }, 60000); // 60000ms = 1 minute 31 | 32 | // Clean up the interval when component unmounts 33 | return () => clearInterval(intervalId); 34 | }, []); 35 | 36 | if (account.lastProposed === undefined || block === undefined) { 37 | return ( 38 | 39 | ); 40 | } 41 | return ( 42 |
43 | 44 | 45 | 46 | 47 | 48 | Last block:{" "} 49 | {hidden 50 | ? "about *** ago" 51 | : formatDistanceToNow(new Date(block.timestamp * 1000), { 52 | addSuffix: true, 53 | })} 54 | 55 | 56 | 57 | 62 | Block #{account.lastProposed} 63 | {" "} 64 | {format(new Date(block.timestamp * 1000), "PPpp")} 65 | 66 | 67 | 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/address/stats/status/last-heartbeat-badge.tsx: -------------------------------------------------------------------------------- 1 | import { DotBadge } from "@/components/dot-badge"; 2 | import { ExplorerLink } from "@/components/explorer-link"; 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipProvider, 7 | TooltipTrigger, 8 | } from "@/components/ui/mobile-tooltip"; 9 | import { useBlock } from "@/hooks/useBlock"; 10 | import { Account } from "algosdk/client/indexer"; 11 | import { format, formatDistanceToNow } from "date-fns"; 12 | import { HeartPulseIcon } from "lucide-react"; 13 | 14 | export function LastHeartbeatBadge({ account }: { account: Account }) { 15 | const { data: block } = useBlock(account.lastHeartbeat); 16 | 17 | if (account.lastHeartbeat === undefined || block === undefined) { 18 | return ; 19 | } 20 | return ( 21 | 22 | 23 | 24 | 25 | Last heartbeat:{" "} 26 | {formatDistanceToNow(new Date(block.timestamp * 1000), { 27 | addSuffix: true, 28 | })} 29 | 30 | 31 | 32 | {" "} 33 | 38 | Block #{block.round} 39 | {" "} 40 | {format(new Date(block.timestamp * 1000), "PPpp")} 41 | 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/address/stats/status/participation-key-badge.tsx: -------------------------------------------------------------------------------- 1 | import { DotBadge } from "@/components/dot-badge"; 2 | import Spinner from "@/components/spinner"; 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipProvider, 7 | TooltipTrigger, 8 | } from "@/components/ui/mobile-tooltip"; 9 | import { useAverageBlockTime } from "@/hooks/useAverageBlockTime"; 10 | import { Account } from "algosdk/client/indexer"; 11 | import { format, formatDistanceToNow } from "date-fns"; 12 | import { KeyRoundIcon } from "lucide-react"; 13 | 14 | export function ParticipationKeyBadge({ account }: { account: Account }) { 15 | const { data: averageBlockTime, isPending } = useAverageBlockTime(); 16 | 17 | if (isPending) return ; 18 | 19 | if (!account.participation) { 20 | return ( 21 | 22 | ); 23 | } 24 | const remainingRounds = account.participation.voteLastValid - account.round; 25 | 26 | if (!averageBlockTime) 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | {`Participation key remaining rounds: ${remainingRounds}`} 34 | 35 | 36 | 37 | Until round {account.participation.voteLastValid} 38 | 39 | 40 | 41 | ); 42 | 43 | const expirationTimeInSeconds = Number(remainingRounds) * averageBlockTime; 44 | const expirationDate = new Date(Date.now() + expirationTimeInSeconds * 1000); 45 | 46 | return ( 47 | 48 | 49 | 50 | 51 | 52 | {`Participation key expiration: ${formatDistanceToNow( 53 | expirationDate, 54 | { addSuffix: true }, 55 | )}`} 56 | 57 | 58 | 59 | Key expire on round {account.participation.voteLastValid}. That's{" "} 60 | {remainingRounds} rounds from now. 61 |
62 | With an average block time of {averageBlockTime} seconds, it will 63 | expire approximately on {format(expirationDate, "PPpp")} 64 |
65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/address/stats/status/status-badge.tsx: -------------------------------------------------------------------------------- 1 | import { DotBadge } from "@/components/dot-badge"; 2 | import { Account } from "algosdk/client/indexer"; 3 | 4 | export function StatusBadge({ account }: { account: Account }) { 5 | if (account.status === "Offline") { 6 | return ; 7 | } 8 | if (account.status === "Online") { 9 | return ; 10 | } 11 | return null; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/address/stats/status/status.tsx: -------------------------------------------------------------------------------- 1 | import { useAccount } from "@/hooks/useAccounts"; 2 | import { ResolvedAddress } from "../../../heatmap/types"; 3 | import Spinner from "@/components/spinner.tsx"; 4 | import { BalanceCard } from "./balance-card"; 5 | import { BalanceThresholdBadge } from "./balance-badge"; 6 | import { IncentiveEligibilityBadge } from "./incentive-eligibility-badge"; 7 | import { LastHeartbeatBadge } from "./last-heartbeat-badge"; 8 | import { ParticipationKeyBadge } from "./participation-key-badge"; 9 | import { StatusBadge } from "./status-badge"; 10 | import { AnxietyCard } from "./anxiety-card"; 11 | 12 | export default function AccountStatus({ 13 | address, 14 | }: { 15 | address: ResolvedAddress; 16 | }) { 17 | const { data: account, isPending, isError, error } = useAccount(address); 18 | 19 | if (isPending) { 20 | return ; 21 | } 22 | 23 | if (isError) { 24 | return ( 25 |
26 | Error fetching account status: {error.message} 27 |
28 | ); 29 | } 30 | 31 | return ( 32 |
33 |
34 |
35 | 36 |
37 |
38 | 39 | 40 | 41 |
42 |
43 | 44 | 45 |
46 |
47 |
48 | 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/algo-amount-display.tsx: -------------------------------------------------------------------------------- 1 | import { AlgoAmount } from "@algorandfoundation/algokit-utils/types/amount"; 2 | import AlgorandLogo from "@/components/algorand-logo.tsx"; 3 | import { animate, motion, useMotionValue } from "motion/react"; 4 | import { useEffect, useState } from "react"; 5 | import { useAlgoPrice } from "@/hooks/useAlgoPrice"; 6 | 7 | export default function AlgoAmountDisplay({ 8 | microAlgoAmount, 9 | className, 10 | showAnimation = true, 11 | showUsdValue = true, 12 | hidden = false, 13 | }: { 14 | microAlgoAmount: bigint | number; 15 | className?: string; 16 | showAnimation?: boolean; 17 | showUsdValue?: boolean; 18 | hidden?: boolean; 19 | }) { 20 | // Ensure microAlgoAmount is a BigInt 21 | const algoAmount = new AlgoAmount({ 22 | microAlgos: 23 | typeof microAlgoAmount === "number" 24 | ? BigInt(Math.round(microAlgoAmount)) 25 | : microAlgoAmount, 26 | }); 27 | 28 | const value = useMotionValue(0); 29 | const [displayValue, setDisplayValue] = useState("0.000"); 30 | 31 | const { price: algoPrice } = useAlgoPrice(); 32 | 33 | useEffect(() => { 34 | const algoValue = Number(algoAmount.algos); 35 | 36 | if (!showAnimation) { 37 | // Skip animation if showAnimation is false 38 | setDisplayValue(formatNumber(algoValue)); 39 | return; 40 | } 41 | 42 | const controls = animate(value, algoValue, { 43 | duration: 0.5, 44 | ease: "easeOut", 45 | onUpdate: (latest) => { 46 | setDisplayValue(formatNumber(latest)); 47 | }, 48 | }); 49 | 50 | return controls.stop; 51 | }, [algoAmount.algos, showAnimation, value]); 52 | 53 | // Format number with commas for thousands and fixed 3 decimal places 54 | function formatNumber(num: number): string { 55 | return new Intl.NumberFormat("en-US", { 56 | minimumFractionDigits: 3, 57 | maximumFractionDigits: 3, 58 | }).format(num); 59 | } 60 | 61 | // Format USD value 62 | function formatUsdValue(algoValue: number, price: number): string { 63 | const usdValue = algoValue * price; 64 | return new Intl.NumberFormat("en-US", { 65 | style: "currency", 66 | currency: "USD", 67 | minimumFractionDigits: 2, 68 | maximumFractionDigits: 2, 69 | }).format(usdValue); 70 | } 71 | 72 | const algoValue = Number(algoAmount.algos); 73 | const usdValue = 74 | algoPrice && showUsdValue ? formatUsdValue(algoValue, algoPrice) : null; 75 | 76 | return ( 77 | 78 | 79 | 85 | {hidden ? "*****" : displayValue} 86 | 87 | 88 | 89 | {showUsdValue && usdValue && ( 90 | 96 | {hidden ? "*****" : usdValue} 97 | 98 | )} 99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/components/algorand-logo.tsx: -------------------------------------------------------------------------------- 1 | export default function AlgorandLogo({ className }: { className?: string }) { 2 | return ( 3 | 11 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/coins-spread.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "motion/react"; 2 | import { cn } from "@/lib/utils.ts"; 3 | 4 | function getRandomSize() { 5 | const sizes = [ 6 | "sm:size-16", 7 | "sm:size-18", 8 | "sm:size-20", 9 | "sm:size-22", 10 | "sm:size-24", 11 | ]; 12 | return sizes[Math.floor(Math.random() * sizes.length)]; 13 | } 14 | 15 | function getRandomRotationDelta() { 16 | return Math.floor(Math.random() * 40) - 20; 17 | } 18 | 19 | function getRandomRotationPositionDelta() { 20 | return Math.floor(Math.random() * 40) - 20; 21 | } 22 | 23 | function getRandomDelayAndDuration() { 24 | return Math.random() * 0.5 + 0.3; 25 | } 26 | 27 | const CoinsSpread = () => { 28 | return ( 29 |
30 |
31 | 49 |
50 |
51 | 69 |
70 |
71 | 89 |
90 |
91 | 109 |
110 |
111 | 129 |
130 |
131 | 149 |
150 |
151 | ); 152 | }; 153 | 154 | export default CoinsSpread; 155 | -------------------------------------------------------------------------------- /src/components/copy-to-clipboard.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { motion } from "motion/react"; 3 | import { ClipboardCheckIcon, CopyIcon } from "lucide-react"; 4 | 5 | const CopyButton = ({ 6 | address, 7 | small = false, 8 | }: { 9 | address: string; 10 | small?: boolean; 11 | }) => { 12 | const [copied, setCopied] = useState(false); 13 | 14 | const copyToClipboard = (e: React.MouseEvent) => { 15 | e.preventDefault(); 16 | e.stopPropagation(); 17 | navigator.clipboard.writeText(address); 18 | setCopied(true); 19 | setTimeout(() => setCopied(false), 2000); // Reset after 2 seconds 20 | }; 21 | 22 | return ( 23 | 29 | {copied ? ( 30 | 31 | ) : ( 32 | 33 | )} 34 | {!small && (copied ? "Copied" : "Copy Address")} 35 | 36 | ); 37 | }; 38 | 39 | export default CopyButton; 40 | -------------------------------------------------------------------------------- /src/components/dot-badge.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | export type DotBadgeColor = 4 | | "red" 5 | | "yellow" 6 | | "green" 7 | | "blue" 8 | | "indigo" 9 | | "purple" 10 | | "pink"; 11 | 12 | interface DotBadgeProps { 13 | label: string; 14 | color: DotBadgeColor; 15 | className?: string; 16 | } 17 | 18 | const colorClasses: Record = { 19 | red: "fill-red-500 dark:fill-red-400", 20 | yellow: "fill-yellow-500 dark:fill-yellow-400", 21 | green: "fill-green-500 dark:fill-green-400", 22 | blue: "fill-blue-500 dark:fill-blue-400", 23 | indigo: "fill-indigo-500 dark:fill-indigo-400", 24 | purple: "fill-purple-500 dark:fill-purple-400", 25 | pink: "fill-pink-500 dark:fill-pink-400", 26 | }; 27 | 28 | export function DotBadge({ label, color, className }: DotBadgeProps) { 29 | return ( 30 | 36 | 43 | {label} 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/components/error.tsx: -------------------------------------------------------------------------------- 1 | export function Error() { 2 | return ( 3 |
4 |

5 | Cannot load transactions 6 |

7 |

8 | Sorry, we couldn’t get your transactions. Please verify your address and 9 | try again later. 10 |

11 |

12 | We rely on Nodely to fetch your transactions. If you are experiencing 13 | issues, please check{" "} 14 | their status page 15 |

16 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/explorer-link.tsx: -------------------------------------------------------------------------------- 1 | interface ExplorerLinkProps { 2 | id: string; 3 | children: React.ReactNode; 4 | className?: string; 5 | type: "address" | "transaction" | "block"; 6 | } 7 | 8 | const alloExplorerPrefixConfig = { 9 | address: "account", 10 | transaction: "tx", 11 | block: "block", 12 | }; 13 | 14 | export function ExplorerLink({ 15 | id, 16 | children, 17 | className, 18 | type, 19 | }: ExplorerLinkProps) { 20 | return ( 21 | 26 | {children} 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import CopyButton from "@/components/copy-to-clipboard.tsx"; 2 | 3 | const Footer = () => { 4 | return ( 5 |
6 |
7 |

8 | Brought to you by{" "} 9 | 13 | @cryptomalgo 14 | 15 |

16 |

17 | ☕️ If you like this website, you can buy me a coffee by sending some 18 | Algo to{" "} 19 | 20 | noderewards.algo{" "} 21 | 22 | 23 |

24 | 25 |

26 | Data kindly provided by{" "} 27 | 31 | Nodely 32 | 33 |

34 | 35 |

36 | Not affiliated with The Algorand Foundation or any other entity. 37 |

38 |
39 |
40 | ); 41 | }; 42 | 43 | export default Footer; 44 | -------------------------------------------------------------------------------- /src/components/github-corner.tsx: -------------------------------------------------------------------------------- 1 | export function GithubCorner() { 2 | return ( 3 | 11 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/heatmap/heatmap.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useCallback, useState, useRef } from "react"; 2 | import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; 3 | import { Block } from "algosdk/client/indexer"; 4 | import MonthView from "@/components/heatmap/month-view.tsx"; 5 | import { DayWithRewards, DisplayMonth } from "@/components/heatmap/types.ts"; 6 | function generateDays( 7 | month: number, 8 | year: number, 9 | startOnMonday: boolean = true, 10 | ): Date[] { 11 | // Create dates without time components to avoid timezone issues 12 | const firstDayOfMonth = new Date(year, month, 1); 13 | const lastDayOfMonth = new Date(year, month + 1, 0); 14 | 15 | const start = new Date(firstDayOfMonth); 16 | // Use getDay instead of getUTCDay 17 | while (start.getDay() !== (startOnMonday ? 1 : 0)) { 18 | start.setDate(start.getDate() - 1); 19 | } 20 | 21 | const end = new Date(lastDayOfMonth); 22 | while (end.getDay() !== (startOnMonday ? 0 : 6)) { 23 | end.setDate(end.getDate() + 1); 24 | } 25 | 26 | const days: Date[] = []; 27 | const current = new Date(start); 28 | 29 | while (current <= end) { 30 | days.push(new Date(current)); 31 | current.setDate(current.getDate() + 1); 32 | } 33 | 34 | return days; 35 | } 36 | 37 | const Heatmap: React.FC<{ blocks: Block[] }> = ({ blocks }) => { 38 | const [displayMonths, setDisplayMonths] = useState(() => { 39 | const now = new Date(); 40 | const currentMonth = now.getMonth(); 41 | const previousMonth = currentMonth === 0 ? 11 : currentMonth - 1; 42 | const twoMonthsAgo = 43 | currentMonth <= 1 ? 11 + (currentMonth - 1) : currentMonth - 2; 44 | const currentYear = now.getFullYear(); 45 | const previousYear = currentMonth === 0 ? currentYear - 1 : currentYear; 46 | const twoYearsAgo = currentMonth <= 1 ? currentYear - 1 : currentYear; 47 | 48 | return [ 49 | { month: twoMonthsAgo, year: twoYearsAgo }, 50 | { month: previousMonth, year: previousYear }, 51 | { month: currentMonth, year: currentYear }, 52 | ]; 53 | }); 54 | 55 | // Process transactions once into a date-based lookup map 56 | const { transactionsByDate, maxCount } = useMemo(() => { 57 | const dateMap = new Map(); 58 | let maxCount = 0; 59 | 60 | blocks.forEach((block) => { 61 | if (!block.timestamp) return; 62 | 63 | const date = new Date(block.timestamp * 1000); 64 | const dateStr = date.toLocaleDateString("en-US"); 65 | 66 | const existing = dateMap.get(dateStr) || { count: 0, totalAmount: 0 }; 67 | existing.count += 1; 68 | existing.totalAmount += block?.proposerPayout ?? 0; 69 | 70 | dateMap.set(dateStr, existing); 71 | 72 | if (existing.count > maxCount) maxCount = existing.count; 73 | }); 74 | 75 | return { transactionsByDate: dateMap, maxCount: Math.max(maxCount, 1) }; 76 | }, [blocks]); 77 | 78 | // Cache generated days to avoid redundant calculations 79 | const daysCache = useRef(new Map()); 80 | 81 | const getDaysWithRewards = useCallback( 82 | (month: number, year: number): DayWithRewards[] => { 83 | const cacheKey = `${month}-${year}`; 84 | let days = daysCache.current.get(cacheKey); 85 | 86 | if (!days) { 87 | days = generateDays(month, year, true); 88 | daysCache.current.set(cacheKey, days); 89 | } 90 | 91 | return days.map((day) => { 92 | const dateStr = day.toLocaleDateString("en-US"); 93 | const dayData = transactionsByDate.get(dateStr) || { 94 | count: 0, 95 | totalAmount: 0, 96 | }; 97 | 98 | return { 99 | date: dateStr, 100 | count: dayData.count, 101 | totalAmount: dayData.totalAmount, 102 | }; 103 | }); 104 | }, 105 | [transactionsByDate], 106 | ); 107 | 108 | const handlePreviousMonth = () => { 109 | setDisplayMonths((prev) => { 110 | const newMonth = prev[0].month === 0 ? 11 : prev[0].month - 1; 111 | const newYear = prev[0].month === 0 ? prev[0].year - 1 : prev[0].year; 112 | return [{ month: newMonth, year: newYear }, prev[0], prev[1]]; 113 | }); 114 | }; 115 | 116 | const handleNextMonth = () => { 117 | setDisplayMonths((prev) => { 118 | const newMonth = prev[2].month === 11 ? 0 : prev[2].month + 1; 119 | const newYear = prev[2].month === 11 ? prev[2].year + 1 : prev[2].year; 120 | return [prev[1], prev[2], { month: newMonth, year: newYear }]; 121 | }); 122 | }; 123 | 124 | return ( 125 |
126 |
127 | 135 | 143 | {displayMonths.map((month) => ( 144 | 151 | ))} 152 |
153 |
154 | ); 155 | }; 156 | 157 | export default React.memo(Heatmap); 158 | -------------------------------------------------------------------------------- /src/components/heatmap/month-view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AlgoAmountDisplay from "@/components/algo-amount-display.tsx"; 3 | import DayView from "./day-view"; 4 | import { DayWithRewards } from "./types"; 5 | import NumberDisplay from "@/components/number-display.tsx"; 6 | 7 | const MonthView: React.FC<{ 8 | month: number; 9 | year: number; 10 | daysWithRewards: DayWithRewards[]; 11 | maxRewardCount: number; 12 | }> = ({ month, year, daysWithRewards, maxRewardCount }) => { 13 | const monthName = new Date(year, month).toLocaleString("default", { 14 | month: "long", 15 | }); 16 | 17 | const totalRewards = daysWithRewards.reduce((sum, day) => { 18 | const dayDate = new Date(day.date); 19 | const isCurrentMonth = dayDate.getMonth() === month; 20 | const isCurrentYear = dayDate.getFullYear() === year; 21 | return isCurrentMonth && isCurrentYear ? sum + day.count : sum; 22 | }, 0); 23 | 24 | const totalAmount = daysWithRewards.reduce((sum, day) => { 25 | const dayDate = new Date(day.date); 26 | const isCurrentMonth = dayDate.getMonth() === month; 27 | const isCurrentYear = dayDate.getFullYear() === year; 28 | 29 | return isCurrentMonth && isCurrentYear ? sum + day.totalAmount : sum; 30 | }, 0); 31 | 32 | return ( 33 |
34 |

35 | {monthName} 36 |

37 |
38 | 39 | blocks 40 | 41 | 42 |
43 |
44 |
M
45 |
T
46 |
W
47 |
T
48 |
F
49 |
S
50 |
S
51 |
52 |
53 | {daysWithRewards.map((day, dayIdx) => ( 54 | 62 | ))} 63 |
64 |
65 | ); 66 | }; 67 | 68 | export default MonthView; 69 | -------------------------------------------------------------------------------- /src/components/heatmap/types.ts: -------------------------------------------------------------------------------- 1 | export interface DayWithRewards { 2 | date: string; 3 | count: number; 4 | totalAmount: number; 5 | } 6 | 7 | export interface DisplayMonth { 8 | month: number; 9 | year: number; 10 | } 11 | 12 | export type ResolvedAddress = { 13 | nfd: string | null; 14 | address: string; 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/number-display.tsx: -------------------------------------------------------------------------------- 1 | import { animate, motion, useMotionValue } from "motion/react"; 2 | import { useEffect, useState } from "react"; 3 | 4 | interface NumberDisplayProps { 5 | value: number; 6 | className?: string; 7 | duration?: number; 8 | formatOptions?: Intl.NumberFormatOptions; 9 | animate?: boolean; 10 | } 11 | 12 | export default function NumberDisplay({ 13 | value, 14 | className, 15 | duration = 0.5, 16 | formatOptions, 17 | animate: shouldAnimate = true, 18 | }: NumberDisplayProps) { 19 | const motionValue = useMotionValue(0); 20 | const [displayValue, setDisplayValue] = useState(0); 21 | 22 | useEffect(() => { 23 | if (!shouldAnimate) { 24 | setDisplayValue(value); 25 | return; 26 | } 27 | 28 | const controls = animate(motionValue, value, { 29 | duration, 30 | ease: "easeOut", 31 | onUpdate: (latest) => { 32 | setDisplayValue(Math.round(latest * 1000) / 1000); 33 | }, 34 | }); 35 | 36 | return controls.stop; 37 | }, [value, duration, motionValue, shouldAnimate]); 38 | 39 | const formattedValue = formatOptions 40 | ? new Intl.NumberFormat(undefined, formatOptions).format(displayValue) 41 | : displayValue.toString(); 42 | 43 | return ( 44 | 51 | {formattedValue} 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/search-bar.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useState } from "react"; 2 | import { useNavigate } from "@tanstack/react-router"; 3 | import AlgorandLogo from "@/components/algorand-logo.tsx"; 4 | import { displayAlgoAddress } from "@/lib/utils.ts"; 5 | import { XIcon } from "lucide-react"; 6 | 7 | export default function SearchBar() { 8 | const [addresses, setAddresses] = useState([]); 9 | const [inputValue, setInputValue] = useState(""); 10 | const [isInputValid, setIsInputValid] = useState(true); 11 | const navigate = useNavigate(); 12 | 13 | const validateAddress = (address: string): boolean => { 14 | const trimmedAddress = address.trim(); 15 | // Check if it's a 58-char alphanumeric string or ends with .algo (case insensitive) 16 | return ( 17 | /^[A-Za-z0-9]{58}$/.test(trimmedAddress) || 18 | /\.algo$/i.test(trimmedAddress) 19 | ); 20 | }; 21 | 22 | const handleSubmit = (e: FormEvent) => { 23 | e.preventDefault(); 24 | 25 | // Create the final list of addresses to use for navigation 26 | const finalAddresses = [...addresses]; 27 | 28 | // Add the current input if it's not empty and valid 29 | if (inputValue.trim() !== "" && validateAddress(inputValue)) { 30 | finalAddresses.push(inputValue.trim()); 31 | } else if (inputValue.trim() !== "") { 32 | setIsInputValid(false); 33 | return; 34 | } 35 | 36 | // Only navigate if we have addresses 37 | if (finalAddresses.length > 0) { 38 | const uniqueAddresses = [ 39 | ...new Set( 40 | finalAddresses.map((address: string) => address.trim().toUpperCase()), 41 | ), 42 | ]; 43 | navigate({ 44 | to: "/$addresses", 45 | params: { 46 | addresses: uniqueAddresses.join(","), 47 | }, 48 | search: (prev) => ({ 49 | hideBalance: prev.hideBalance ?? false, 50 | theme: prev.theme ?? "system", 51 | statsPanelTheme: prev.statsPanelTheme ?? "indigo", 52 | }), 53 | }); 54 | } 55 | }; 56 | 57 | const addAddress = () => { 58 | const trimmedInput = inputValue.trim(); 59 | 60 | if (trimmedInput === "") return; 61 | 62 | if (!validateAddress(trimmedInput)) { 63 | setIsInputValid(false); 64 | return; 65 | } 66 | 67 | if (!addresses.includes(trimmedInput)) { 68 | setAddresses([...addresses, trimmedInput]); 69 | setInputValue(""); 70 | setIsInputValid(true); 71 | } 72 | }; 73 | 74 | const handleInputChange = (e: React.ChangeEvent) => { 75 | setInputValue(e.target.value); 76 | setIsInputValid(true); // Reset validation state when input changes 77 | }; 78 | 79 | const removeAddress = (indexToRemove: number) => { 80 | setAddresses(addresses.filter((_, index) => index !== indexToRemove)); 81 | }; 82 | 83 | const handleKeyDown = (e: React.KeyboardEvent) => { 84 | if (e.key === "Enter") { 85 | e.preventDefault(); // Prevent form submission 86 | addAddress(); 87 | } 88 | }; 89 | 90 | return ( 91 |
92 |
93 | 100 | 101 | {/* Address tags */} 102 |
103 | {addresses.length > 0 && 104 | addresses.map((address, index) => ( 105 |
109 | 110 | {address.length === 58 111 | ? displayAlgoAddress(address) 112 | : address} 113 | 114 | 121 |
122 | ))} 123 |
124 | 125 |
126 |
127 | 143 | 144 | 145 |
146 | 147 | 154 |
155 | {!isInputValid && ( 156 |

157 | Please enter a valid Algorand address (58 characters) or NFD domain 158 | (.algo) 159 |

160 | )} 161 | 168 |
169 |
170 | ); 171 | } 172 | -------------------------------------------------------------------------------- /src/components/spinner.tsx: -------------------------------------------------------------------------------- 1 | const Spinner = () => { 2 | return ( 3 |
4 | 12 | 19 | 27 | 28 |
29 | ); 30 | }; 31 | 32 | export default Spinner; 33 | -------------------------------------------------------------------------------- /src/components/staking-cta.tsx: -------------------------------------------------------------------------------- 1 | const StakingCta = () => { 2 | return ( 3 |
4 |
5 |
6 |

7 | Not staking yet? 8 |

9 |

10 | Algorand is designed for decentralization. You can collect rewards 11 | for running nodes or staking Algo, doing your part to secure the 12 | network. 13 |

14 | 22 | 41 |
42 |
43 |
44 | ); 45 | }; 46 | 47 | export default StakingCta; 48 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react"; 2 | 3 | export type Theme = "dark" | "light"; 4 | export type ThemeSetting = "dark" | "light" | "system"; 5 | 6 | interface ThemeContextType { 7 | theme: Theme; 8 | themeSetting: ThemeSetting; 9 | setThemeSetting: (theme: ThemeSetting) => void; 10 | } 11 | 12 | const ThemeContext = createContext(undefined); 13 | 14 | const getThemeFromUrl = (): ThemeSetting => { 15 | if (typeof window === "undefined") return "system"; 16 | 17 | const url = new URL(window.location.href); 18 | const themeParam = url.searchParams.get("theme"); 19 | 20 | if ( 21 | themeParam === "dark" || 22 | themeParam === "light" || 23 | themeParam === "system" 24 | ) { 25 | return themeParam; 26 | } 27 | 28 | return "system"; 29 | }; 30 | 31 | const setThemeSettingInUrl = (themeSetting: ThemeSetting) => { 32 | if (typeof window === "undefined") return; 33 | 34 | const url = new URL(window.location.href); 35 | url.searchParams.set("theme", themeSetting); 36 | 37 | // Update URL without reloading the page 38 | window.history.replaceState({}, "", url.toString()); 39 | }; 40 | 41 | export function ThemeProvider({ 42 | children, 43 | defaultThemeSetting = "system", 44 | }: { 45 | children: React.ReactNode; 46 | defaultThemeSetting?: ThemeSetting; 47 | }) { 48 | const [themeSetting, setThemeSettingState] = useState(() => { 49 | return getThemeFromUrl() || defaultThemeSetting; 50 | }); 51 | 52 | const [actualTheme, setActualTheme] = useState("light"); 53 | 54 | const setThemeSetting = (newTheme: ThemeSetting) => { 55 | setThemeSettingState(newTheme); 56 | setThemeSettingInUrl(newTheme); 57 | }; 58 | 59 | useEffect(() => { 60 | const root = window.document.documentElement; 61 | 62 | root.classList.remove("light", "dark"); 63 | 64 | if (themeSetting === "system") { 65 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 66 | .matches 67 | ? "dark" 68 | : "light"; 69 | root.classList.add(systemTheme); 70 | setActualTheme(systemTheme); 71 | } else { 72 | root.classList.add(themeSetting); 73 | setActualTheme(themeSetting); 74 | } 75 | }, [themeSetting]); 76 | 77 | useEffect(() => { 78 | const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); 79 | 80 | const handleChange = () => { 81 | if (themeSetting === "system") { 82 | const root = window.document.documentElement; 83 | const newTheme = mediaQuery.matches ? "dark" : "light"; 84 | root.classList.remove("light", "dark"); 85 | root.classList.add(newTheme); 86 | setActualTheme(newTheme); 87 | } 88 | }; 89 | 90 | mediaQuery.addEventListener("change", handleChange); 91 | 92 | return () => { 93 | mediaQuery.removeEventListener("change", handleChange); 94 | }; 95 | }, [themeSetting]); 96 | 97 | return ( 98 | 105 | 109 | {children} 110 | 111 | ); 112 | } 113 | 114 | export const useTheme = (): ThemeContextType => { 115 | const context = useContext(ThemeContext); 116 | 117 | if (!context) { 118 | throw new Error("useTheme must be used within a ThemeProvider"); 119 | } 120 | 121 | return context; 122 | }; 123 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-card text-card-foreground", 12 | destructive: 13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | }, 20 | ); 21 | 22 | function Alert({ 23 | className, 24 | variant, 25 | ...props 26 | }: React.ComponentProps<"div"> & VariantProps) { 27 | return ( 28 |
34 | ); 35 | } 36 | 37 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { 38 | return ( 39 |
47 | ); 48 | } 49 | 50 | function AlertDescription({ 51 | className, 52 | ...props 53 | }: React.ComponentProps<"div">) { 54 | return ( 55 |
63 | ); 64 | } 65 | 66 | export { Alert, AlertTitle, AlertDescription }; 67 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", 16 | outline: 17 | "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 25 | sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5", 26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 27 | icon: "size-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | }, 35 | ); 36 | 37 | function Button({ 38 | className, 39 | variant, 40 | size, 41 | asChild = false, 42 | ...props 43 | }: React.ComponentProps<"button"> & 44 | VariantProps & { 45 | asChild?: boolean; 46 | }) { 47 | const Comp = asChild ? Slot : "button"; 48 | 49 | return ( 50 | 55 | ); 56 | } 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /src/components/ui/calendar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ChevronLeft, ChevronRight } from "lucide-react"; 3 | import { DayPicker } from "react-day-picker"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | import { buttonVariants } from "@/components/ui/button"; 7 | 8 | function Calendar({ 9 | className, 10 | classNames, 11 | showOutsideDays = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground", 46 | range_end: 47 | "day-range-end !bg-accent rounded-r-md [&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground", 48 | range_middle: 49 | "aria-selected:bg-accent aria-selected:text-accent-foreground", 50 | selected: cn( 51 | props.mode === "range" 52 | ? "bg-primary hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground" 53 | : "[&>button]:focus:bg-primary [&>button]:focus:text-primary-foreground", 54 | ), 55 | today: "bg-accent text-accent-foreground !rounded-md", 56 | outside: 57 | "day-outside text-muted-foreground opacity-50 !aria-selected:bg-accent/50 !aria-selected:text-muted-foreground !aria-selected:opacity-30", 58 | disabled: "text-muted-foreground opacity-50", 59 | hidden: "invisible", 60 | ...classNames, 61 | }} 62 | components={{ 63 | Chevron: ({ ...props }) => 64 | props.orientation === "left" ? ( 65 | 66 | ) : ( 67 | 68 | ), 69 | }} 70 | {...props} 71 | /> 72 | ); 73 | } 74 | 75 | export { Calendar }; 76 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ); 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ); 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ); 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ); 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ); 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ); 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ); 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | }; 93 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 3 | import { CheckIcon } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | function Checkbox({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 20 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | export { Checkbox }; 31 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 3 | import { XIcon } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | function Dialog({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return ; 11 | } 12 | 13 | function DialogTrigger({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return ; 17 | } 18 | 19 | function DialogPortal({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ; 23 | } 24 | 25 | function DialogClose({ 26 | ...props 27 | }: React.ComponentProps) { 28 | return ; 29 | } 30 | 31 | function DialogOverlay({ 32 | className, 33 | ...props 34 | }: React.ComponentProps) { 35 | return ( 36 | 44 | ); 45 | } 46 | 47 | function DialogContent({ 48 | className, 49 | children, 50 | ...props 51 | }: React.ComponentProps) { 52 | return ( 53 | 54 | 55 | 63 | {children} 64 | 65 | 66 | Close 67 | 68 | 69 | 70 | ); 71 | } 72 | 73 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 74 | return ( 75 |
80 | ); 81 | } 82 | 83 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { 84 | return ( 85 |
93 | ); 94 | } 95 | 96 | function DialogTitle({ 97 | className, 98 | ...props 99 | }: React.ComponentProps) { 100 | return ( 101 | 106 | ); 107 | } 108 | 109 | function DialogDescription({ 110 | className, 111 | ...props 112 | }: React.ComponentProps) { 113 | return ( 114 | 119 | ); 120 | } 121 | 122 | export { 123 | Dialog, 124 | DialogClose, 125 | DialogContent, 126 | DialogDescription, 127 | DialogFooter, 128 | DialogHeader, 129 | DialogOverlay, 130 | DialogPortal, 131 | DialogTitle, 132 | DialogTrigger, 133 | }; 134 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ); 19 | } 20 | 21 | export { Input }; 22 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as LabelPrimitive from "@radix-ui/react-label"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | function Label({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 19 | ); 20 | } 21 | 22 | export { Label }; 23 | -------------------------------------------------------------------------------- /src/components/ui/mobile-tooltip.tsx: -------------------------------------------------------------------------------- 1 | //From https://github.com/shadcn-ui/ui/issues/2402 2 | 3 | import { createContext, useContext, useEffect, useState } from "react"; 4 | import { 5 | TooltipProvider as OriginalTooltipProvider, 6 | Tooltip as OriginalTooltip, 7 | TooltipTrigger as OriginalTooltipTrigger, 8 | TooltipContent as OriginalTooltipContent, 9 | } from "./tooltip"; 10 | import { Popover, PopoverTrigger, PopoverContent } from "./popover"; 11 | import { 12 | TooltipContentProps, 13 | TooltipProps, 14 | TooltipTriggerProps, 15 | TooltipProviderProps, 16 | } from "@radix-ui/react-tooltip"; 17 | import { 18 | PopoverContentProps, 19 | PopoverProps, 20 | PopoverTriggerProps, 21 | } from "@radix-ui/react-popover"; 22 | import { cn } from "@/lib/utils"; 23 | 24 | const TouchContext = createContext(undefined); 25 | const useTouch = () => useContext(TouchContext); 26 | 27 | export const TooltipProvider = ({ 28 | children, 29 | ...props 30 | }: TooltipProviderProps) => { 31 | const [isTouch, setTouch] = useState(undefined); 32 | 33 | useEffect(() => { 34 | setTouch(window.matchMedia("(pointer: coarse)").matches); 35 | }, []); 36 | 37 | return ( 38 | 39 | {children} 40 | 41 | ); 42 | }; 43 | 44 | export const Tooltip = (props: TooltipProps & PopoverProps) => { 45 | const isTouch = useTouch(); 46 | 47 | return isTouch ? : ; 48 | }; 49 | 50 | export const TooltipTrigger = ( 51 | props: TooltipTriggerProps & PopoverTriggerProps, 52 | ) => { 53 | const isTouch = useTouch(); 54 | return isTouch ? ( 55 | 56 | ) : ( 57 | 58 | ); 59 | }; 60 | 61 | export const TooltipContent = ( 62 | props: TooltipContentProps & PopoverContentProps, 63 | ) => { 64 | const isTouch = useTouch(); 65 | 66 | return isTouch ? ( 67 | 74 | ) : ( 75 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | function Popover({ 7 | ...props 8 | }: React.ComponentProps) { 9 | return ; 10 | } 11 | 12 | function PopoverTrigger({ 13 | ...props 14 | }: React.ComponentProps) { 15 | return ; 16 | } 17 | 18 | function PopoverContent({ 19 | className, 20 | align = "center", 21 | sideOffset = 4, 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | 36 | 37 | ); 38 | } 39 | 40 | function PopoverAnchor({ 41 | ...props 42 | }: React.ComponentProps) { 43 | return ; 44 | } 45 | 46 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; 47 | -------------------------------------------------------------------------------- /src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as SelectPrimitive from "@radix-ui/react-select"; 3 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | function Select({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return ; 11 | } 12 | 13 | function SelectGroup({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return ; 17 | } 18 | 19 | function SelectValue({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ; 23 | } 24 | 25 | function SelectTrigger({ 26 | className, 27 | children, 28 | ...props 29 | }: React.ComponentProps) { 30 | return ( 31 | span]:line-clamp-1", 35 | className, 36 | )} 37 | {...props} 38 | > 39 | {children} 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | 47 | function SelectContent({ 48 | className, 49 | children, 50 | position = "popper", 51 | ...props 52 | }: React.ComponentProps) { 53 | return ( 54 | 55 | 66 | 67 | 74 | {children} 75 | 76 | 77 | 78 | 79 | ); 80 | } 81 | 82 | function SelectLabel({ 83 | className, 84 | ...props 85 | }: React.ComponentProps) { 86 | return ( 87 | 92 | ); 93 | } 94 | 95 | function SelectItem({ 96 | className, 97 | children, 98 | ...props 99 | }: React.ComponentProps) { 100 | return ( 101 | 109 | 110 | 111 | 112 | 113 | 114 | {children} 115 | 116 | ); 117 | } 118 | 119 | function SelectSeparator({ 120 | className, 121 | ...props 122 | }: React.ComponentProps) { 123 | return ( 124 | 129 | ); 130 | } 131 | 132 | function SelectScrollUpButton({ 133 | className, 134 | ...props 135 | }: React.ComponentProps) { 136 | return ( 137 | 145 | 146 | 147 | ); 148 | } 149 | 150 | function SelectScrollDownButton({ 151 | className, 152 | ...props 153 | }: React.ComponentProps) { 154 | return ( 155 | 163 | 164 | 165 | ); 166 | } 167 | 168 | export { 169 | Select, 170 | SelectContent, 171 | SelectGroup, 172 | SelectItem, 173 | SelectLabel, 174 | SelectScrollDownButton, 175 | SelectScrollUpButton, 176 | SelectSeparator, 177 | SelectTrigger, 178 | SelectValue, 179 | }; 180 | -------------------------------------------------------------------------------- /src/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as SliderPrimitive from "@radix-ui/react-slider"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | function Slider({ 7 | className, 8 | defaultValue, 9 | value, 10 | min = 0, 11 | max = 100, 12 | ...props 13 | }: React.ComponentProps) { 14 | const _values = React.useMemo( 15 | () => 16 | Array.isArray(value) 17 | ? value 18 | : Array.isArray(defaultValue) 19 | ? defaultValue 20 | : [min, max], 21 | [value, defaultValue, min, max], 22 | ); 23 | 24 | return ( 25 | 37 | 43 | 49 | 50 | {Array.from({ length: _values.length }, (_, index) => ( 51 | 56 | ))} 57 | 58 | ); 59 | } 60 | 61 | export { Slider }; 62 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "next-themes"; 2 | import { Toaster as Sonner, ToasterProps } from "sonner"; 3 | 4 | const Toaster = ({ ...props }: ToasterProps) => { 5 | const { theme = "system" } = useTheme(); 6 | 7 | return ( 8 | 20 | ); 21 | }; 22 | 23 | export { Toaster }; 24 | -------------------------------------------------------------------------------- /src/components/ui/start-date-picker.tsx: -------------------------------------------------------------------------------- 1 | import { format, subDays } from "date-fns"; 2 | import { Calendar as CalendarIcon } from "lucide-react"; 3 | import { cn } from "@/lib/utils"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Calendar } from "@/components/ui/calendar"; 6 | import { 7 | Popover, 8 | PopoverContent, 9 | PopoverTrigger, 10 | } from "@/components/ui/popover"; 11 | 12 | interface StartDatePickerProps { 13 | startDate: Date | undefined; 14 | onStartDateChange: (date: Date | undefined) => void; 15 | minDate?: Date; 16 | maxDate?: Date; 17 | disabled?: boolean; 18 | className?: string; 19 | placeholder?: string; 20 | showReset?: boolean; 21 | onReset?: () => void; 22 | } 23 | 24 | export function StartDatePicker({ 25 | startDate, 26 | onStartDateChange, 27 | minDate, 28 | maxDate, 29 | disabled = false, 30 | className, 31 | placeholder = "Select start date", 32 | showReset = false, 33 | onReset, 34 | }: StartDatePickerProps) { 35 | // Format date to display in UTC 36 | const formatDateUTC = (date: Date): string => { 37 | return format(date, "LLL dd, y"); 38 | }; 39 | 40 | const endDate = new Date(); // Always today 41 | 42 | const handleLast7Days = () => { 43 | const thirtyDaysAgo = subDays(endDate, 7); 44 | // Ensure we don't go before the minimum date 45 | const startDateToSet = 46 | minDate && thirtyDaysAgo < minDate ? minDate : thirtyDaysAgo; 47 | onStartDateChange(startDateToSet); 48 | }; 49 | 50 | const handleLast30Days = () => { 51 | const thirtyDaysAgo = subDays(endDate, 30); 52 | // Ensure we don't go before the minimum date 53 | const startDateToSet = 54 | minDate && thirtyDaysAgo < minDate ? minDate : thirtyDaysAgo; 55 | onStartDateChange(startDateToSet); 56 | }; 57 | 58 | const handleLast90Days = () => { 59 | const ninetyDaysAgo = subDays(endDate, 90); 60 | // Ensure we don't go before the minimum date 61 | const startDateToSet = 62 | minDate && ninetyDaysAgo < minDate ? minDate : ninetyDaysAgo; 63 | onStartDateChange(startDateToSet); 64 | }; 65 | 66 | return ( 67 |
68 | {/* Quick date selection buttons */} 69 |
70 | 79 | 88 | 97 | {showReset && onReset && ( 98 | 107 | )} 108 |
109 | 110 | {/* Date picker */} 111 |
112 | 113 | 114 | 132 | 133 | 134 | { 141 | if (minDate && date < minDate) return true; 142 | if (maxDate && date > maxDate) return true; 143 | return false; 144 | }} 145 | numberOfMonths={1} 146 | /> 147 | 148 | 149 |
150 |
151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /src/components/ui/toggle-group.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; 3 | import { type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | import { toggleVariants } from "@/components/ui/toggle"; 7 | 8 | const ToggleGroupContext = React.createContext< 9 | VariantProps 10 | >({ 11 | size: "default", 12 | variant: "default", 13 | }); 14 | 15 | function ToggleGroup({ 16 | className, 17 | variant, 18 | size, 19 | children, 20 | ...props 21 | }: React.ComponentProps & 22 | VariantProps) { 23 | return ( 24 | 34 | 35 | {children} 36 | 37 | 38 | ); 39 | } 40 | 41 | function ToggleGroupItem({ 42 | className, 43 | children, 44 | variant, 45 | size, 46 | ...props 47 | }: React.ComponentProps & 48 | VariantProps) { 49 | const context = React.useContext(ToggleGroupContext); 50 | 51 | return ( 52 | 66 | {children} 67 | 68 | ); 69 | } 70 | 71 | export { ToggleGroup, ToggleGroupItem }; 72 | -------------------------------------------------------------------------------- /src/components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TogglePrimitive from "@radix-ui/react-toggle"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const toggleVariants = cva( 10 | "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", 11 | { 12 | variants: { 13 | variant: { 14 | default: "bg-transparent", 15 | outline: 16 | "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", 17 | }, 18 | size: { 19 | default: "h-9 px-2 min-w-9", 20 | sm: "h-8 px-1.5 min-w-8", 21 | lg: "h-10 px-2.5 min-w-10", 22 | }, 23 | }, 24 | defaultVariants: { 25 | variant: "default", 26 | size: "default", 27 | }, 28 | }, 29 | ); 30 | 31 | function Toggle({ 32 | className, 33 | variant, 34 | size, 35 | ...props 36 | }: React.ComponentProps & 37 | VariantProps) { 38 | return ( 39 | 44 | ); 45 | } 46 | 47 | export { Toggle, toggleVariants }; 48 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | function TooltipProvider({ 7 | delayDuration = 0, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ); 17 | } 18 | 19 | function Tooltip({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | function TooltipTrigger({ 30 | ...props 31 | }: React.ComponentProps) { 32 | return ; 33 | } 34 | 35 | function TooltipContent({ 36 | className, 37 | sideOffset = 4, 38 | children, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 43 | 52 | {children} 53 | 54 | 55 | 56 | ); 57 | } 58 | 59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 60 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ALGO_BALANCE_THRESHOLD_FOR_REWARDS = 30_000; 2 | export const AVERAGE_DAY_IN_MONTH = Number((365 / 12).toFixed(2)); // 30.42 3 | export const BLOCK_ANXIETY_BLOG_POST_URL = 4 | "https://valar-staking.medium.com/where-is-my-block-70113da4d817"; 5 | -------------------------------------------------------------------------------- /src/hooks/useAccounts.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedAddress } from "@/components/heatmap/types"; 2 | import { indexerClient } from "@/lib/indexer-client"; 3 | import { useQueries, useQuery } from "@tanstack/react-query"; 4 | 5 | const getAccount = (address: string) => { 6 | return indexerClient 7 | .lookupAccountByID(address) 8 | .do() 9 | .then((res) => res.account); 10 | }; 11 | 12 | export const useAccount = (address: ResolvedAddress) => { 13 | return useQuery({ 14 | queryKey: ["account", address.address], 15 | queryFn: () => getAccount(address.address), 16 | refetchInterval: 1000 * 60 * 15, // 15 minutes 17 | }); 18 | }; 19 | 20 | export const useAccounts = (addresses: ResolvedAddress[]) => { 21 | return useQueries({ 22 | queries: addresses.map((address) => ({ 23 | queryKey: ["account", address.address], 24 | queryFn: () => getAccount(address.address), 25 | refetchInterval: 1000 * 60 * 15, // 15 minutes 26 | })), 27 | combine: (results) => { 28 | return { 29 | data: results.map((result) => result.data), 30 | pending: results.some((result) => result.isPending), 31 | }; 32 | }, 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /src/hooks/useAlgoPrice.ts: -------------------------------------------------------------------------------- 1 | // src/hooks/useAlgoPrice.ts 2 | import { useEffect, useState } from "react"; 3 | 4 | type BinancePrice = { 5 | symbol: string; 6 | price: string; 7 | }; 8 | 9 | // Cache the price data with timestamp 10 | let priceCache: { 11 | price: number; 12 | timestamp: number; 13 | } | null = null; 14 | 15 | // Cache validity in milliseconds (5 minutes) 16 | const CACHE_VALIDITY = 5 * 60 * 1000; 17 | 18 | // Pending request promise 19 | let pendingRequest: Promise | null = null; 20 | 21 | export function useAlgoPrice() { 22 | const [price, setPrice] = useState(priceCache?.price || null); 23 | const [loading, setLoading] = useState(!priceCache); 24 | const [error, setError] = useState(null); 25 | 26 | useEffect(() => { 27 | async function fetchPrice() { 28 | // Return cached price if it's still valid 29 | if (priceCache && Date.now() - priceCache.timestamp < CACHE_VALIDITY) { 30 | setPrice(priceCache.price); 31 | setLoading(false); 32 | return; 33 | } 34 | 35 | setLoading(true); 36 | 37 | try { 38 | // If there's already a pending request, reuse it 39 | if (!pendingRequest) { 40 | pendingRequest = fetch( 41 | "https://www.binance.com/api/v3/ticker/price?symbol=ALGOUSDC", 42 | ) 43 | .then((response) => { 44 | if (!response.ok) { 45 | throw new Error( 46 | `Binance API responded with status: ${response.status}`, 47 | ); 48 | } 49 | return response.json(); 50 | }) 51 | .then((data: BinancePrice) => { 52 | const numericPrice = parseFloat(data.price); 53 | 54 | // Update cache 55 | priceCache = { 56 | price: numericPrice, 57 | timestamp: Date.now(), 58 | }; 59 | 60 | // Clear the pending request reference 61 | setTimeout(() => { 62 | pendingRequest = null; 63 | }, 0); 64 | 65 | return numericPrice; 66 | }); 67 | } 68 | 69 | // Use the shared promise 70 | const numericPrice = await pendingRequest; 71 | setPrice(numericPrice); 72 | setError(null); 73 | } catch (err) { 74 | setError(err instanceof Error ? err : new Error(String(err))); 75 | // Clear pending request on error 76 | pendingRequest = null; 77 | } finally { 78 | setLoading(false); 79 | } 80 | } 81 | 82 | fetchPrice(); 83 | }, []); 84 | 85 | return { price, loading, error }; 86 | } 87 | -------------------------------------------------------------------------------- /src/hooks/useAlgorandAddress.ts: -------------------------------------------------------------------------------- 1 | import { resolveNFD } from "@/queries/getResolvedNFD"; 2 | import * as React from "react"; 3 | import { ResolvedAddress } from "@/components/heatmap/types.ts"; 4 | 5 | export const useAlgorandAddresses = (addresses: string[]) => { 6 | const [resolvedAddresses, setResolvedAddresses] = React.useState< 7 | ResolvedAddress[] 8 | >([]); 9 | const [loading, setLoading] = React.useState(true); 10 | const [hasError, setError] = React.useState(false); 11 | 12 | React.useEffect(() => { 13 | const resolveAddress = async () => { 14 | try { 15 | // Use Promise.all to wait for all the async operations to complete 16 | const resolved = await Promise.all( 17 | addresses.map(async (address) => { 18 | if (address.toLowerCase().endsWith(".algo")) { 19 | return { address: await resolveNFD(address), nfd: address }; 20 | } 21 | return { address, nfd: null }; 22 | }), 23 | ); 24 | 25 | setResolvedAddresses(resolved); 26 | } catch (err) { 27 | console.error(err); 28 | setError(true); 29 | } finally { 30 | setLoading(false); 31 | } 32 | }; 33 | resolveAddress(); 34 | }, [addresses]); 35 | 36 | return { resolvedAddresses, loading, hasError }; 37 | }; 38 | -------------------------------------------------------------------------------- /src/hooks/useAverageBlockTime.ts: -------------------------------------------------------------------------------- 1 | import { indexerClient } from "@/lib/indexer-client"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | 4 | const DEFAULT_BLOCK_TIME = 2.8; // Default block time in seconds 5 | const getAverageBlockTime = () => { 6 | const minutesAgo = 5; 7 | const dateAgo = new Date(new Date().getTime() - minutesAgo * 60 * 1000); 8 | 9 | return indexerClient 10 | .searchForBlockHeaders() 11 | .limit(50) 12 | .afterTime(dateAgo.toISOString()) 13 | .do() 14 | .then((res) => { 15 | const blocks = res.blocks; 16 | const timeDifferences: number[] = []; 17 | 18 | for (let i = 0; i < blocks.length - 1; i++) { 19 | const currentBlock = blocks[i]; 20 | const nextBlock = blocks[i + 1]; 21 | 22 | if (currentBlock.timestamp && nextBlock.timestamp) { 23 | const timeDiff = nextBlock.timestamp - currentBlock.timestamp; 24 | timeDifferences.push(timeDiff); 25 | } 26 | } 27 | 28 | if (timeDifferences.length === 0) { 29 | console.error( 30 | "No time differences found, returning default block time", 31 | ); 32 | return DEFAULT_BLOCK_TIME; 33 | } 34 | 35 | const averageTimeDiff = 36 | timeDifferences.reduce((sum, diff) => sum + diff, 0) / 37 | timeDifferences.length; 38 | 39 | return Math.round(averageTimeDiff * 100) / 100; 40 | }); 41 | }; 42 | export const useAverageBlockTime = () => { 43 | return useQuery({ 44 | queryKey: ["averageBlockTime"], 45 | queryFn: getAverageBlockTime, 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /src/hooks/useBlock.ts: -------------------------------------------------------------------------------- 1 | import { indexerClient } from "@/lib/indexer-client"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | 4 | const getBlock = (round: number) => { 5 | return indexerClient.lookupBlock(round).do(); 6 | }; 7 | 8 | export const useBlock = (round?: number) => { 9 | return useQuery({ 10 | queryKey: ["block", round], 11 | enabled: round !== undefined, 12 | queryFn: () => { 13 | // Safety check inside query function 14 | if (round === undefined) { 15 | throw new Error("Block round is required"); 16 | } 17 | return getBlock(round); 18 | }, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/hooks/useCurrentRound.ts: -------------------------------------------------------------------------------- 1 | import { indexerClient } from "@/lib/indexer-client"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | 4 | const getCurrentRound = () => { 5 | return indexerClient 6 | .searchForBlockHeaders() 7 | .limit(1) 8 | .do() 9 | .then((res) => res.currentRound); 10 | }; 11 | export const useCurrentRound = () => { 12 | return useQuery({ 13 | queryKey: ["currentRound"], 14 | queryFn: getCurrentRound, 15 | refetchInterval: 1000 * 60, // Refetch every minute 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /src/hooks/useIsSmallScreen.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | /** 4 | * Custom hook that returns a boolean indicating if the current screen width 5 | * is less than the provided threshold 6 | * 7 | * @param threshold - The width threshold in pixels (default: 768px for md breakpoint) 8 | * @returns boolean - True if screen width is less than threshold 9 | */ 10 | export function useIsSmallScreen(threshold = 768): boolean { 11 | const [isSmallScreen, setIsSmallScreen] = useState(false); 12 | 13 | useEffect(() => { 14 | // Initial check 15 | const checkScreenSize = () => { 16 | setIsSmallScreen(window.innerWidth < threshold); 17 | }; 18 | 19 | // Set initial value 20 | checkScreenSize(); 21 | 22 | // Add event listener for resize 23 | window.addEventListener("resize", checkScreenSize); 24 | 25 | // Cleanup 26 | return () => { 27 | window.removeEventListener("resize", checkScreenSize); 28 | }; 29 | }, [threshold]); 30 | 31 | return isSmallScreen; 32 | } 33 | -------------------------------------------------------------------------------- /src/hooks/useRewardTransactions.ts: -------------------------------------------------------------------------------- 1 | import { getAccountsBlockHeaders } from "@/queries/getAccountsBlockHeaders"; 2 | import * as React from "react"; 3 | import { Block } from "algosdk/client/indexer"; 4 | import { ResolvedAddress } from "@/components/heatmap/types.ts"; 5 | 6 | export const useBlocks = (addresses: ResolvedAddress[]) => { 7 | const [data, setData] = React.useState([]); 8 | const [loading, setLoading] = React.useState(true); 9 | const [hasError, setError] = React.useState(false); 10 | 11 | React.useEffect(() => { 12 | if (addresses.length === 0) { 13 | return; 14 | } 15 | 16 | const loadData = async () => { 17 | try { 18 | setLoading(true); 19 | const result = await getAccountsBlockHeaders(addresses); 20 | setData(result); 21 | } catch (err) { 22 | console.error(err); 23 | setError(true); 24 | } finally { 25 | setLoading(false); 26 | } 27 | }; 28 | 29 | loadData(); 30 | }, [addresses]); 31 | 32 | return { data, loading, hasError }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/hooks/useStakeInfo.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | 3 | //https://afmetrics.api.nodely.io/v1/api-docs/#get-/v1/realtime/participation/online 4 | 5 | type RewardsStats = { 6 | apy_pct: number; // Annual Percentage Yield (annualized rate with compounding) 7 | as_of_round: string; // Data valid as of round 8 | avg_payout_24hr: number; // Avg block reward payout in microAlgo 9 | eligible: number; // Number of rewards eligible online accounts 10 | eligible_stake_micro_algo: string; // Total eligible stake in microAlgo 11 | max_stake: string; // Maximum stake 12 | online: number; // Number of online accounts 13 | online_above30k: number; // Number of online accounts above 30K Algo 14 | p10_stake: string; // 10th percentile stake 15 | p25_stake: string; // 25th percentile stake 16 | p50_stake: string; // Median stake 17 | reward_rate_pct: number; // Last 24hr reward rate in pct (annualized) 18 | stake_micro_algo: string; // Total stake in microAlgo 19 | }; 20 | 21 | const fetchStakeInfo = async (): Promise => { 22 | const response = await fetch( 23 | "https://afmetrics.api.nodely.io/v1/realtime/participation/online", 24 | ); 25 | 26 | if (!response.ok) { 27 | throw new Error( 28 | `Failed to fetch stake information: ${response.status} ${response.statusText}`, 29 | ); 30 | } 31 | 32 | return response.json(); 33 | }; 34 | 35 | export function useStakeInfo() { 36 | return useQuery({ 37 | queryKey: ["stakeInfo"], 38 | queryFn: fetchStakeInfo, 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/csv-columns.ts: -------------------------------------------------------------------------------- 1 | export type CsvColumnId = 2 | | "timestamp" 3 | | "utc_date" 4 | | "utc_time" 5 | | "address" 6 | | "round" 7 | | "payout_micro_algo" 8 | | "payout_algo" 9 | | "vestige_vwap" 10 | | "binance_open_time" 11 | | "binance_close_time" 12 | | "binance_open_price" 13 | | "binance_close_price" 14 | | "binance_high_price" 15 | | "binance_low_price"; 16 | 17 | export type CsvColumn = { 18 | id: CsvColumnId; 19 | label: string; 20 | isPriceColumn: boolean; 21 | example?: string; 22 | help?: string; 23 | }; 24 | 25 | export const CSV_COLUMNS: CsvColumn[] = [ 26 | { 27 | id: "timestamp", 28 | label: "Timestamp", 29 | isPriceColumn: false, 30 | example: "1651234567", 31 | help: "Unix timestamp (seconds since epoch) of the block proposal", 32 | }, 33 | { 34 | id: "utc_date", 35 | label: "UTC Date", 36 | isPriceColumn: false, 37 | example: "2023-05-01", 38 | help: "Calendar date in YYYY-MM-DD format (UTC Timezone)", 39 | }, 40 | { 41 | id: "utc_time", 42 | label: "UTC Time", 43 | isPriceColumn: false, 44 | example: "12:34:56Z", 45 | help: "Time of day in HH:MM:SSZ format (UTC Timezone)", 46 | }, 47 | { 48 | id: "address", 49 | label: "Address", 50 | isPriceColumn: false, 51 | example: "ABC...XYZ", 52 | help: "Algorand wallet address of the block proposer", 53 | }, 54 | { 55 | id: "round", 56 | label: "Round", 57 | isPriceColumn: false, 58 | example: "25123456", 59 | help: "Algorand block round number", 60 | }, 61 | { 62 | id: "payout_micro_algo", 63 | label: "Payout (microAlgo)", 64 | isPriceColumn: false, 65 | example: "1234567", 66 | help: "Block proposer reward in microAlgos (1 Algo = 1,000,000 microAlgos)", 67 | }, 68 | { 69 | id: "payout_algo", 70 | label: "Payout (Algo)", 71 | isPriceColumn: false, 72 | example: "1.234567", 73 | help: "Block proposer reward converted to Algo units", 74 | }, 75 | { 76 | id: "vestige_vwap", 77 | label: "Vestige VWAP (USDC)", 78 | isPriceColumn: true, 79 | example: "0.123456", 80 | help: "Vestige's volume-weighted average price (in USDC) for Algorand for a given day (UTC)", 81 | }, 82 | { 83 | id: "binance_open_time", 84 | label: "Binance Open Time", 85 | isPriceColumn: true, 86 | example: "1651219200000", 87 | help: "Timestamp for the start of the day's trading period on Binance", 88 | }, 89 | { 90 | id: "binance_close_time", 91 | label: "Binance Close Time", 92 | isPriceColumn: true, 93 | example: "1651305599999", 94 | help: "Timestamp for the end of the day's trading period on Binance", 95 | }, 96 | { 97 | id: "binance_open_price", 98 | label: "Binance Open Price (USDC)", 99 | isPriceColumn: true, 100 | example: "0.29450000", 101 | help: "Opening price of Algorand in USDC for the day", 102 | }, 103 | { 104 | id: "binance_close_price", 105 | label: "Binance Close Price (USDC)", 106 | isPriceColumn: true, 107 | example: "0.29750000", 108 | help: "Closing price of Algorand in USDC for the day", 109 | }, 110 | { 111 | id: "binance_high_price", 112 | label: "Binance High Price (USDC)", 113 | isPriceColumn: true, 114 | example: "0.30100000", 115 | help: "Highest price of Algorand in USDC during the day", 116 | }, 117 | { 118 | id: "binance_low_price", 119 | label: "Binance Low Price (USDC)", 120 | isPriceColumn: true, 121 | example: "0.28950000", 122 | help: "Lowest price of Algorand in USDC during the day", 123 | }, 124 | ]; 125 | -------------------------------------------------------------------------------- /src/lib/date-utils.ts: -------------------------------------------------------------------------------- 1 | // src/lib/date-utils.ts 2 | export function generateDateRange(startTimestamp?: number): string[] { 3 | // Create an array of all dates between first timestamp and today 4 | const firstDate = startTimestamp 5 | ? new Date(startTimestamp * 1000) 6 | : new Date(); 7 | 8 | // Set to start of day in local timezone 9 | firstDate.setHours(0, 0, 0, 0); 10 | 11 | const endDate = new Date(); // Today's date 12 | const allDates = []; 13 | const currentDate = new Date(firstDate); 14 | 15 | // Generate all dates between first date and today (inclusive) 16 | while (currentDate <= endDate) { 17 | // Use date formatting that respects local timezone instead of ISO 18 | const year = currentDate.getFullYear(); 19 | const month = String(currentDate.getMonth() + 1).padStart(2, "0"); 20 | const day = String(currentDate.getDate()).padStart(2, "0"); 21 | const dateStr = `${year}-${month}-${day}`; 22 | allDates.push(dateStr); 23 | 24 | // Move to next day 25 | currentDate.setDate(currentDate.getDate() + 1); 26 | } 27 | 28 | return allDates; 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/indexer-client.ts: -------------------------------------------------------------------------------- 1 | import algosdk from "algosdk"; 2 | 3 | export const indexerClient = new algosdk.Indexer( 4 | "", 5 | "https://mainnet-idx.4160.nodely.dev", 6 | 443, 7 | ); 8 | -------------------------------------------------------------------------------- /src/lib/mockTimezone.ts: -------------------------------------------------------------------------------- 1 | import { register, unregister, TimeZone } from "timezone-mock"; 2 | 3 | /** 4 | * Converts standard GMT notation to timezone-mock format 5 | * Note: timezone-mock uses reversed notation where Etc/GMT+2 means GMT-2 6 | */ 7 | export function setTimezone(zone: `GMT${"+" | "-"}${number}`): () => void { 8 | // Extract the sign and number from the input 9 | const match = zone.match(/^GMT([+-])(\d+)$/); 10 | if (!match) { 11 | throw new Error( 12 | `Invalid timezone format: ${zone}. Use format "GMT+n" or "GMT-n"`, 13 | ); 14 | } 15 | 16 | const [, sign, numStr] = match; 17 | const num = parseInt(numStr, 10); 18 | 19 | if (num < 0 || num > 14) { 20 | throw new Error(`Timezone offset must be between 0 and 14 (got ${num})`); 21 | } 22 | 23 | // Reverse the sign for timezone-mock's reversed notation 24 | const reversedSign = sign === "+" ? "-" : "+"; 25 | const mockZone = `Etc/GMT${reversedSign}${num}` as TimeZone; 26 | 27 | register(mockZone); 28 | 29 | // Return cleanup function 30 | return () => unregister(); 31 | } 32 | 33 | /** 34 | * Type-safe GMT timezone offsets 35 | * Allows values from GMT-12 to GMT+14 36 | */ 37 | export type GMTOffset = 38 | | "GMT+0" 39 | | "GMT+1" 40 | | "GMT+2" 41 | | "GMT+3" 42 | | "GMT+4" 43 | | "GMT+5" 44 | | "GMT+6" 45 | | "GMT+7" 46 | | "GMT+8" 47 | | "GMT+9" 48 | | "GMT+10" 49 | | "GMT+11" 50 | | "GMT+12" 51 | | "GMT+13" 52 | | "GMT+14" 53 | | "GMT-0" 54 | | "GMT-1" 55 | | "GMT-2" 56 | | "GMT-3" 57 | | "GMT-4" 58 | | "GMT-5" 59 | | "GMT-6" 60 | | "GMT-7" 61 | | "GMT-8" 62 | | "GMT-9" 63 | | "GMT-10" 64 | | "GMT-11" 65 | | "GMT-12"; 66 | 67 | /** 68 | * Type-safe timezone setter 69 | * @returns A cleanup function to restore the timezone 70 | */ 71 | export function setGMTTimezone(zone: GMTOffset): () => void { 72 | return setTimezone(zone); 73 | } 74 | 75 | /** 76 | * @returns A cleanup function to restore the timezone 77 | */ 78 | export function setUTCTimezone(): () => void { 79 | register("UTC"); 80 | return () => unregister(); 81 | } 82 | -------------------------------------------------------------------------------- /src/lib/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { formatMinutes } from "./utils"; 2 | import { describe, it, expect } from "vitest"; 3 | 4 | describe("formatMinutes", () => { 5 | it("should format minutes into hours and minutes when appropriate", () => { 6 | // Basic cases 7 | expect(formatMinutes(70)).toBe("1h 10m"); 8 | expect(formatMinutes(0)).toBe("0m"); 9 | expect(formatMinutes(1)).toBe("1m"); 10 | 11 | // Edge cases 12 | expect(formatMinutes(60)).toBe("1h 0m"); 13 | expect(formatMinutes(59)).toBe("59m"); 14 | expect(formatMinutes(61)).toBe("1h 1m"); 15 | 16 | // Larger numbers 17 | expect(formatMinutes(60000)).toBe("1000h 0m"); 18 | expect(formatMinutes(90)).toBe("1h 30m"); 19 | expect(formatMinutes(120)).toBe("2h 0m"); 20 | expect(formatMinutes(1439)).toBe("23h 59m"); 21 | }); 22 | 23 | it("should handle decimal values by rounding down", () => { 24 | expect(formatMinutes(70.8)).toBe("1h 10m"); 25 | expect(formatMinutes(1.9)).toBe("1m"); 26 | expect(formatMinutes(0.4)).toBe("0m"); 27 | }); 28 | 29 | it("should throw an error for negative values", () => { 30 | expect(() => formatMinutes(-70)).toThrow("Minutes cannot be negative"); 31 | expect(() => formatMinutes(-1)).toThrow("Minutes cannot be negative"); 32 | expect(() => formatMinutes(-60)).toThrow("Minutes cannot be negative"); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function displayAlgoAddress(address: string, lettersToDisplay = 5) { 9 | return `${address.slice(0, lettersToDisplay)}...${address.slice( 10 | -lettersToDisplay, 11 | )}`; 12 | } 13 | 14 | export function calculateAPYAndProjection( 15 | rewards: number, // rewards received in the period 16 | algoStaked: number, // amount of Algo staked/held 17 | days: number, // period duration 18 | ): { apy: number; projectedTotal: number } { 19 | if (algoStaked === 0 || days === 0) return { apy: 0, projectedTotal: 0 }; 20 | 21 | const apy = parseFloat( 22 | ((rewards / algoStaked) * (365 / days) * 100).toFixed(2), 23 | ); 24 | const projectedTotal = (rewards / days) * 365; 25 | return { apy, projectedTotal }; 26 | } 27 | 28 | export function formatMinutes(minutes: number): string { 29 | if (minutes < 0) { 30 | throw new Error("Minutes cannot be negative"); 31 | } 32 | 33 | // Calculate hours and remaining minutes 34 | const hours = Math.floor(minutes / 60); 35 | const mins = Math.floor(minutes % 60); 36 | 37 | // Format the result 38 | if (hours > 0) { 39 | return `${hours}h ${mins}m`; 40 | } else { 41 | return `${mins}m`; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { scan } from "react-scan"; // must be imported before React and React DOM 2 | 3 | import { StrictMode } from "react"; 4 | import { createRoot } from "react-dom/client"; 5 | import "./App.css"; 6 | import { RouterProvider, createRouter } from "@tanstack/react-router"; 7 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 8 | 9 | import { routeTree } from "./routeTree.gen"; 10 | import { ThemeProvider } from "@/components/theme-provider.tsx"; 11 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 12 | scan({ 13 | enabled: false, 14 | }); 15 | 16 | // Create a new router instance 17 | const router = createRouter({ routeTree }); 18 | const queryClient = new QueryClient(); 19 | 20 | // Register the router instance for type safety 21 | declare module "@tanstack/react-router" { 22 | interface Register { 23 | router: typeof router; 24 | } 25 | } 26 | 27 | createRoot(document.getElementById("root")!).render( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | , 36 | ); 37 | -------------------------------------------------------------------------------- /src/queries/getAccountsBlockHeaders.ts: -------------------------------------------------------------------------------- 1 | import { executePaginatedRequest } from "@algorandfoundation/algokit-utils"; 2 | import { Block, BlockHeadersResponse } from "algosdk/client/indexer"; 3 | import { ResolvedAddress } from "@/components/heatmap/types.ts"; 4 | import { indexerClient } from "@/lib/indexer-client"; 5 | 6 | export async function getAccountsBlockHeaders( 7 | addresses: ResolvedAddress[], 8 | ): Promise { 9 | const blocks = await executePaginatedRequest( 10 | (response: BlockHeadersResponse) => { 11 | return response.blocks; 12 | }, 13 | (nextToken) => { 14 | let s = indexerClient 15 | .searchForBlockHeaders() 16 | .minRound(46512890) 17 | .limit(1000) 18 | .proposers(addresses.map((a: ResolvedAddress) => a.address)); 19 | if (nextToken) { 20 | s = s.nextToken(nextToken); 21 | } 22 | return s; 23 | }, 24 | ); 25 | return blocks.filter( 26 | (block) => block.proposerPayout && block.proposerPayout > 0, 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/queries/getResolvedNFD.ts: -------------------------------------------------------------------------------- 1 | export async function resolveNFD(nfd: string): Promise { 2 | try { 3 | const response = await fetch( 4 | `https://api.nf.domains/nfd/${nfd.toLowerCase()}`, 5 | ); 6 | const data = await response.json(); 7 | return data.depositAccount; 8 | } catch (error) { 9 | console.error("Error resolving NFD:", error); 10 | return ""; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/routeTree.gen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-nocheck 4 | 5 | // noinspection JSUnusedGlobalSymbols 6 | 7 | // This file was automatically generated by TanStack Router. 8 | // You should NOT make any changes in this file as it will be overwritten. 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 | 11 | // Import Routes 12 | 13 | import { Route as rootRoute } from "./routes/__root"; 14 | import { Route as AddressesImport } from "./routes/$addresses"; 15 | import { Route as IndexImport } from "./routes/index"; 16 | 17 | // Create/Update Routes 18 | 19 | const AddressesRoute = AddressesImport.update({ 20 | id: "/$addresses", 21 | path: "/$addresses", 22 | getParentRoute: () => rootRoute, 23 | } as any); 24 | 25 | const IndexRoute = IndexImport.update({ 26 | id: "/", 27 | path: "/", 28 | getParentRoute: () => rootRoute, 29 | } as any); 30 | 31 | // Populate the FileRoutesByPath interface 32 | 33 | declare module "@tanstack/react-router" { 34 | interface FileRoutesByPath { 35 | "/": { 36 | id: "/"; 37 | path: "/"; 38 | fullPath: "/"; 39 | preLoaderRoute: typeof IndexImport; 40 | parentRoute: typeof rootRoute; 41 | }; 42 | "/$addresses": { 43 | id: "/$addresses"; 44 | path: "/$addresses"; 45 | fullPath: "/$addresses"; 46 | preLoaderRoute: typeof AddressesImport; 47 | parentRoute: typeof rootRoute; 48 | }; 49 | } 50 | } 51 | 52 | // Create and export the route tree 53 | 54 | export interface FileRoutesByFullPath { 55 | "/": typeof IndexRoute; 56 | "/$addresses": typeof AddressesRoute; 57 | } 58 | 59 | export interface FileRoutesByTo { 60 | "/": typeof IndexRoute; 61 | "/$addresses": typeof AddressesRoute; 62 | } 63 | 64 | export interface FileRoutesById { 65 | __root__: typeof rootRoute; 66 | "/": typeof IndexRoute; 67 | "/$addresses": typeof AddressesRoute; 68 | } 69 | 70 | export interface FileRouteTypes { 71 | fileRoutesByFullPath: FileRoutesByFullPath; 72 | fullPaths: "/" | "/$addresses"; 73 | fileRoutesByTo: FileRoutesByTo; 74 | to: "/" | "/$addresses"; 75 | id: "__root__" | "/" | "/$addresses"; 76 | fileRoutesById: FileRoutesById; 77 | } 78 | 79 | export interface RootRouteChildren { 80 | IndexRoute: typeof IndexRoute; 81 | AddressesRoute: typeof AddressesRoute; 82 | } 83 | 84 | const rootRouteChildren: RootRouteChildren = { 85 | IndexRoute: IndexRoute, 86 | AddressesRoute: AddressesRoute, 87 | }; 88 | 89 | export const routeTree = rootRoute 90 | ._addFileChildren(rootRouteChildren) 91 | ._addFileTypes(); 92 | 93 | /* ROUTE_MANIFEST_START 94 | { 95 | "routes": { 96 | "__root__": { 97 | "filePath": "__root.tsx", 98 | "children": [ 99 | "/", 100 | "/$addresses" 101 | ] 102 | }, 103 | "/": { 104 | "filePath": "index.tsx" 105 | }, 106 | "/$addresses": { 107 | "filePath": "$addresses.tsx" 108 | } 109 | } 110 | } 111 | ROUTE_MANIFEST_END */ 112 | -------------------------------------------------------------------------------- /src/routes/$addresses.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | import AddressView from "@/components/address/address-view"; 3 | import { ThemeSetting } from "@/components/theme-provider"; 4 | 5 | type AddressSearch = { 6 | hideBalance: boolean; 7 | theme: ThemeSetting; 8 | statsPanelTheme: "light" | "indigo"; 9 | }; 10 | 11 | export const Route = createFileRoute("/$addresses")({ 12 | component: Address, 13 | validateSearch: (search: Record): AddressSearch => { 14 | return { 15 | hideBalance: search.hideBalance === true, 16 | statsPanelTheme: 17 | typeof search.statsPanelTheme === "string" && 18 | ["light", "indigo"].includes(search.statsPanelTheme) 19 | ? (search.statsPanelTheme as "light" | "indigo") 20 | : "indigo", 21 | 22 | theme: 23 | typeof search.theme === "string" && 24 | ["dark", "light", "system"].includes(search.theme) 25 | ? (search.theme as ThemeSetting) 26 | : "system", 27 | }; 28 | }, 29 | }); 30 | 31 | function Address() { 32 | const { addresses } = Route.useParams(); 33 | return ; 34 | } 35 | -------------------------------------------------------------------------------- /src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { createRootRoute, Outlet } from "@tanstack/react-router"; 2 | import React, { Suspense } from "react"; 3 | import Footer from "@/components/footer.tsx"; 4 | import { Toaster } from "@/components/ui/sonner"; 5 | import { GithubCorner } from "@/components/github-corner"; 6 | 7 | const TanStackRouterDevtools = 8 | process.env.NODE_ENV === "production" 9 | ? () => null // Render nothing in production 10 | : React.lazy(() => 11 | // Lazy load in development 12 | import("@tanstack/react-router-devtools").then((res) => ({ 13 | default: res.TanStackRouterDevtools, 14 | // For Embedded Mode 15 | // default: res.TanStackRouterDevtoolsPanel 16 | })), 17 | ); 18 | 19 | export const Route = createRootRoute({ 20 | component: () => ( 21 | <> 22 | 23 |
24 |
25 | 42 |
43 |
44 |
45 | 46 |
47 | 48 | 49 |