├── preview.png ├── package.json ├── LICENSE ├── .gitignore ├── MMM-NounsProposal.css ├── README.md ├── node_helper.js └── MMM-NounsProposal.js /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XppaiCyberr/MMM-NounsProposal/HEAD/preview.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MMM-NounsProposal", 3 | "version": "1.0.0", 4 | "description": "A MagicMirror module to display Nouns DAO proposal information", 5 | "main": "MMM-NounsProposal.js", 6 | "scripts": { 7 | "lint": "eslint .", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/XppaiCyberr/MMM-NounsProposal.git" 13 | }, 14 | "keywords": [ 15 | "magic mirror", 16 | "smart mirror", 17 | "nouns", 18 | "dao", 19 | "proposal", 20 | "ethereum", 21 | "blockchain" 22 | ], 23 | "author": "XppaiCyberr", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/XppaiCyberr/MMM-NounsProposal/issues" 27 | }, 28 | "homepage": "https://github.com/XppaiCyberr/MMM-NounsProposal#readme", 29 | "dependencies": { 30 | "axios": "^1.6.7", 31 | "viem": "^2.7.9" 32 | } 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 XppaiCyber 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | -------------------------------------------------------------------------------- /MMM-NounsProposal.css: -------------------------------------------------------------------------------- 1 | .nouns-proposal { 2 | width: 100%; 3 | max-width: 400px; 4 | margin: 0 auto; 5 | padding: 5px; 6 | } 7 | 8 | .nouns-proposal .module-header { 9 | text-align: left; 10 | font-size: 0.9em; 11 | margin-bottom: 8px; 12 | color: #fff; 13 | opacity: 0.5; 14 | } 15 | 16 | .proposal-content { 17 | display: flex; 18 | flex-direction: column; 19 | gap: 8px; 20 | transition: opacity 2s ease-in-out; 21 | } 22 | 23 | .proposal-container { 24 | background: rgba(0, 0, 0, 0.2); 25 | border-radius: 4px; 26 | padding: 1px; 27 | transition: opacity 2s ease-in-out; 28 | } 29 | 30 | .proposal-header { 31 | display: flex; 32 | justify-content: space-between; 33 | align-items: center; 34 | margin-bottom: 4px; 35 | padding: 5px; 36 | } 37 | 38 | .prop-id { 39 | color: #46CF89; 40 | font-weight: bold; 41 | font-size: 0.8em; 42 | white-space: nowrap; 43 | overflow: hidden; 44 | text-overflow: ellipsis; 45 | max-width: 75%; /* Limit width to prevent overflowing */ 46 | } 47 | 48 | .status-badge { 49 | font-size: 0.7em; 50 | padding: 2px 6px; 51 | border-radius: 3px; 52 | font-weight: 500; 53 | text-transform: capitalize; 54 | white-space: nowrap; 55 | } 56 | 57 | .status-badge.active { 58 | background-color: #46CF89; 59 | color: #000; 60 | } 61 | 62 | .status-badge.pending { 63 | background-color: #FFB800; 64 | color: #000; 65 | } 66 | 67 | .status-badge.executed { 68 | background-color: #09f143a6; 69 | color: #fff; 70 | } 71 | 72 | .status-badge.defeated { 73 | background-color: #F93A3A; 74 | color: #fff; 75 | } 76 | 77 | .status-badge.queued { 78 | background-color: #4FB3FF; 79 | color: #000; 80 | } 81 | 82 | .status-badge.cancelled { 83 | background-color: #dd0e0e; 84 | color: #fff; 85 | } 86 | 87 | .status-badge.vetoed { 88 | background-color: #F93A3A; 89 | color: #fff; 90 | } 91 | 92 | .proposal-title { 93 | font-size: 0.8em; 94 | color: #fff; 95 | margin-bottom: 6px; 96 | white-space: nowrap; 97 | overflow: hidden; 98 | text-overflow: ellipsis; 99 | padding: 0 5px; 100 | } 101 | 102 | .vote-progress-container { 103 | margin-top: 4px; 104 | padding: 0 5px 5px 5px; 105 | } 106 | 107 | .vote-summary { 108 | font-size: 0.7em; 109 | color: #888; 110 | margin-bottom: 4px; 111 | } 112 | 113 | .vote-progress-bar { 114 | height: 4px; 115 | background: transparent; 116 | border-radius: 2px; 117 | position: relative; 118 | display: flex; 119 | overflow: hidden; 120 | } 121 | 122 | .vote-segment { 123 | height: 100%; 124 | transition: width 0.3s ease; 125 | } 126 | 127 | .vote-segment.for { 128 | background-color: #46CF89; 129 | } 130 | 131 | .vote-segment.abstain { 132 | background-color: #808080; 133 | } 134 | 135 | .vote-segment.against { 136 | background-color: #F93A3A; 137 | } 138 | 139 | .quorum-indicator { 140 | position: absolute; 141 | top: -2px; 142 | width: 2px; 143 | height: 8px; 144 | background-color: rgba(255, 251, 1, 0.986); 145 | transform: translateX(-50%); 146 | } 147 | 148 | .error-message { 149 | color: #ff4444; 150 | text-align: center; 151 | padding: 8px; 152 | font-size: 0.8em; 153 | } 154 | 155 | .dimmed { 156 | color: #888; 157 | text-align: center; 158 | padding: 8px; 159 | font-size: 0.8em; 160 | } 161 | 162 | .pagination { 163 | text-align: center; 164 | font-size: 0.7em; 165 | color: #888; 166 | margin-top: 8px; 167 | padding: 4px; 168 | background: rgba(0, 0, 0, 0.2); 169 | border-radius: 4px; 170 | transition: opacity 2s ease-in-out; 171 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MMM-NounsProposal 2 | 3 | A [MagicMirror²](https://github.com/MichMich/MagicMirror) module that displays recent Nouns DAO governance proposals with voting information. 4 | 5 | ## Screenshot 6 | 7 | ![preview](preview.png) 8 | 9 | ## Features 10 | 11 | - Displays latest Nouns DAO governance proposals 12 | - Shows voting progress with visual indicators 13 | - Pagination for viewing multiple proposals 14 | - Auto-cycling between pages 15 | - Displays proposal status, vote counts, and quorum indicators 16 | - Configurable display options 17 | - Fetches real-time data from Nouns DAO blockchain and API 18 | - ENS name resolution for proposal creators 19 | 20 | ## Installation 21 | 22 | 1. Navigate to your MagicMirror's modules folder: 23 | ```bash 24 | cd ~/MagicMirror/modules/ 25 | ``` 26 | 27 | 2. Clone this repository: 28 | ```bash 29 | git clone https://github.com/XppaiCyberr/MMM-NounsProposal.git 30 | ``` 31 | 32 | 3. Install dependencies: 33 | ```bash 34 | cd MMM-NounsProposal 35 | npm install 36 | ``` 37 | 38 | 4. Add the module to your `config/config.js` file: 39 | ```javascript 40 | { 41 | module: "MMM-NounsProposal", 42 | position: "top_right", 43 | config: { 44 | // See configuration options below 45 | } 46 | } 47 | ``` 48 | 49 | ## Configuration 50 | 51 | | Option | Description | Default | 52 | |--------------------|-----------------------------------------------------------|-------------| 53 | | `updateInterval` | How often to fetch new data (in milliseconds) | `300000` (5 minutes) | 54 | | `header` | Text to display at the top of the module | `"Recent Nouns Proposals"` | 55 | | `maxProposals` | Total number of proposals to fetch | `10` | 56 | | `proposalsPerPage` | Number of proposals to display per page | `5` | 57 | | `cycleInterval` | Time between page changes (in milliseconds) | `10000` (10 seconds) | 58 | | `animationSpeed` | Speed of page transition animations (in milliseconds) | `2000` (2 seconds) | 59 | | `minProposalsPerPage` | Minimum proposals per page to enable cycling | `2` | 60 | | `showProposer` | Whether to display the proposer information | `true` | 61 | | `showRawData` | Debug option to show raw proposal data | `false` | 62 | 63 | ## Example Configuration 64 | 65 | ```javascript 66 | { 67 | module: "MMM-NounsProposal", 68 | position: "top_right", 69 | config: { 70 | header: "Recent Nouns Proposals", 71 | maxProposals: 10, 72 | proposalsPerPage: 3, 73 | cycleInterval: 15000, 74 | showProposer: true 75 | } 76 | } 77 | ``` 78 | 79 | ## How It Works 80 | 81 | This module connects to the Ethereum mainnet to retrieve Nouns DAO proposal information: 82 | 83 | 1. It queries the Nouns governance contract to get the latest proposal ID 84 | 2. Fetches detailed proposal data from the Nouns API 85 | 3. Resolves ENS names for proposal creators when available 86 | 4. Processes voting data to show progress bars and percentages 87 | 5. Displays the information in an easily readable format 88 | 89 | ## Dependencies 90 | 91 | The module requires the following dependencies (automatically installed with `npm install`): 92 | - axios (^1.6.7): For making API requests to the Nouns API 93 | - viem (^2.7.9): For interacting with the Ethereum blockchain 94 | 95 | ## Customization 96 | 97 | The appearance of the module can be customized by modifying the `MMM-NounsProposal.css` file. 98 | 99 | ## Development 100 | 101 | Feel free to contribute to this module by submitting pull requests or reporting issues on GitHub. 102 | 103 | ## Acknowledgments 104 | - https://api.nouns.biz/ for the Proposal API 105 | 106 | 107 | ## License 108 | 109 | MIT -------------------------------------------------------------------------------- /node_helper.js: -------------------------------------------------------------------------------- 1 | const NodeHelper = require("node_helper"); 2 | const axios = require("axios"); 3 | const { createPublicClient, http } = require('viem'); 4 | const { mainnet } = require('viem/chains'); 5 | 6 | // Sleep function for retry 7 | const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); 8 | 9 | // Retry function 10 | const retry = async (fn, retries = 3, delay = 1000) => { 11 | let lastError; 12 | for (let i = 0; i < retries; i++) { 13 | try { 14 | return await fn(); 15 | } catch (error) { 16 | console.log(`Attempt ${i + 1} failed, retrying in ${delay}ms...`); 17 | lastError = error; 18 | await sleep(delay); 19 | delay *= 2; // Exponential backoff 20 | } 21 | } 22 | throw lastError; 23 | }; 24 | 25 | // Helper function to truncate address 26 | const truncateAddress = (address) => { 27 | return address.substring(0, 6) + "..." + address.substring(address.length - 4); 28 | }; 29 | 30 | module.exports = NodeHelper.create({ 31 | start: function() { 32 | console.log("Starting node helper for: " + this.name); 33 | this.client = createPublicClient({ 34 | chain: mainnet, 35 | transport: http('https://eth.llamarpc.com') 36 | }); 37 | 38 | // Define the contract ABI for the proposalCount function 39 | this.nounsGovAbi = [{ 40 | name: 'proposalCount', 41 | type: 'function', 42 | stateMutability: 'view', 43 | inputs: [], 44 | outputs: [{ type: 'uint256' }] 45 | }]; 46 | 47 | // Nouns governance contract address 48 | this.nounsGovAddress = '0x6f3e6272a167e8accb32072d08e0957f9c79223d'; 49 | }, 50 | 51 | socketNotificationReceived: async function(notification, payload) { 52 | if (notification === "FETCH_PROPOSAL_DATA") { 53 | try { 54 | // First get the latest proposal ID from the blockchain 55 | const latestId = await this.getLatestProposalId(); 56 | if (!latestId) { 57 | throw new Error("Failed to retrieve the latest proposal ID"); 58 | } 59 | 60 | // Determine how many proposals to fetch, default to 10 if not specified 61 | const numToFetch = payload.maxProposals || 10; 62 | console.log(`Fetching ${numToFetch} proposals for MMM-NounsProposal, starting from ID ${latestId}`); 63 | 64 | // Fetch proposals starting from the latest ID 65 | const proposals = await this.fetchProposalBatch(latestId, numToFetch); 66 | 67 | // Update the UI with the fetched proposals 68 | this.sendSocketNotification("PROPOSAL_DATA_RESULT", { 69 | data: proposals 70 | }); 71 | } catch (error) { 72 | console.error("Error fetching data:", error); 73 | this.sendSocketNotification("PROPOSAL_DATA_RESULT", { 74 | error: error.message 75 | }); 76 | } 77 | } 78 | }, 79 | 80 | getLatestProposalId: async function() { 81 | try { 82 | console.log("Fetching latest proposal ID from contract..."); 83 | 84 | // Use retry to handle potential network issues 85 | const count = await retry(() => this.client.readContract({ 86 | address: this.nounsGovAddress, 87 | abi: this.nounsGovAbi, 88 | functionName: 'proposalCount' 89 | })); 90 | 91 | const idValue = parseInt(count.toString()); 92 | console.log(`Retrieved latest proposal ID: ${idValue}`); 93 | return idValue; 94 | } catch (error) { 95 | console.error('Error getting proposal count:', error); 96 | return null; 97 | } 98 | }, 99 | 100 | fetchProposalBatch: async function(startId, numToFetch) { 101 | console.log(`Starting batch fetch from ID ${startId}, requesting ${numToFetch} proposals`); 102 | const proposals = []; 103 | let currentId = startId; 104 | let attemptsRemaining = numToFetch + 20; // Additional attempts to ensure we get enough proposals 105 | 106 | // Continue fetching until we have enough proposals or run out of attempts 107 | while (proposals.length < numToFetch && attemptsRemaining > 0) { 108 | try { 109 | console.log(`Fetching proposal #${currentId}...`); 110 | const data = await this.fetchProposalData(currentId); 111 | 112 | if (data) { 113 | console.log(`Successfully fetched proposal #${currentId}: ${data.title}`); 114 | proposals.push(data); 115 | } else { 116 | console.log(`Skipped proposal #${currentId} (no data returned)`); 117 | } 118 | } catch (error) { 119 | console.error(`Failed to fetch proposal #${currentId}:`, error.message); 120 | } 121 | 122 | // Move to the previous proposal and decrease attempts counter 123 | currentId--; 124 | attemptsRemaining--; 125 | 126 | // Short delay to avoid rate limiting 127 | await sleep(200); 128 | } 129 | 130 | console.log(`Fetched ${proposals.length}/${numToFetch} proposals after trying ${numToFetch + 20 - attemptsRemaining} IDs`); 131 | 132 | return proposals.slice(0, numToFetch); 133 | }, 134 | 135 | fetchProposalData: async function(proposalId) { 136 | try { 137 | // Set a timeout to avoid hanging requests 138 | const response = await axios.get(`https://api.nouns.biz/proposal/${proposalId}`, { 139 | timeout: 5000 140 | }); 141 | 142 | const data = response.data; 143 | 144 | // Calculate vote statistics 145 | const votes = data.votes || []; 146 | const forVotes = votes.filter(v => v.support === 'FOR').reduce((sum, v) => sum + v.votes, 0); 147 | const againstVotes = votes.filter(v => v.support === 'AGAINST').reduce((sum, v) => sum + v.votes, 0); 148 | const abstainVotes = votes.filter(v => v.support === 'ABSTAIN').reduce((sum, v) => sum + v.votes, 0); 149 | 150 | // Get proposer ENS name 151 | let proposerDisplay = ''; 152 | if (data.proposer) { 153 | try { 154 | const ensName = await retry(() => this.client.getEnsName({ 155 | address: data.proposer 156 | })); 157 | 158 | if (ensName) { 159 | // If we have an ENS name, just use that 160 | proposerDisplay = ensName; 161 | } else { 162 | // If no ENS name, just use the truncated address 163 | proposerDisplay = truncateAddress(data.proposer); 164 | } 165 | } catch (ensError) { 166 | // In case of error with ENS, fall back to truncated address 167 | proposerDisplay = truncateAddress(data.proposer); 168 | } 169 | } else { 170 | proposerDisplay = 'Anonymous'; 171 | } 172 | 173 | // Prepare the data for the frontend 174 | return { 175 | title: data.title || `Proposal ${proposalId}`, 176 | proposalId: proposalId, 177 | proposerDisplay: proposerDisplay, 178 | quorumVotes: data.quorumVotes || 0, 179 | status: data.status || { currentStatus: 'UNKNOWN' }, 180 | forVotes: forVotes, 181 | againstVotes: againstVotes, 182 | abstainVotes: abstainVotes 183 | }; 184 | 185 | } catch (error) { 186 | console.error(`Error fetching proposal ${proposalId}:`, error.message); 187 | return null; 188 | } 189 | } 190 | }); -------------------------------------------------------------------------------- /MMM-NounsProposal.js: -------------------------------------------------------------------------------- 1 | /* MMM-NounsProposal.js */ 2 | Module.register("MMM-NounsProposal", { 3 | defaults: { 4 | updateInterval: 300000, // 5 minutes 5 | showRawData: false, 6 | header: "Recent Nouns Proposals", 7 | maxProposals: 15, // Total number of proposals to fetch 8 | proposalsPerPage: 5, // Number of proposals per page 9 | cycleInterval: 10000, // Time between page changes (5 seconds) 10 | animationSpeed: 2000, // Animation speed for transitions 11 | minProposalsPerPage: 2, // Minimum proposals per page to enable cycling 12 | showProposer: true // Whether to show proposer info 13 | }, 14 | 15 | requiresVersion: "2.1.0", 16 | 17 | start: function() { 18 | Log.info("Starting module: " + this.name); 19 | this.proposalData = null; 20 | this.loaded = false; 21 | this.error = null; 22 | this.currentPage = 0; 23 | this.cycleTimer = null; 24 | this.scheduleUpdate(); 25 | }, 26 | 27 | getDom: function() { 28 | const wrapper = document.createElement("div"); 29 | wrapper.className = "nouns-proposal"; 30 | 31 | // Add header 32 | const header = document.createElement("header"); 33 | header.className = "module-header"; 34 | header.textContent = this.config.header; 35 | wrapper.appendChild(header); 36 | 37 | // If we're still loading 38 | if (!this.loaded) { 39 | const loading = document.createElement("div"); 40 | loading.className = "dimmed light"; 41 | loading.innerHTML = this.translate("LOADING"); 42 | wrapper.appendChild(loading); 43 | return wrapper; 44 | } 45 | 46 | // If we have an error 47 | if (this.error) { 48 | const errorMessage = document.createElement("div"); 49 | errorMessage.className = "error-message"; 50 | errorMessage.textContent = this.error; 51 | wrapper.appendChild(errorMessage); 52 | return wrapper; 53 | } 54 | 55 | // If we have data 56 | if (this.proposalData && Array.isArray(this.proposalData)) { 57 | Log.debug(`${this.name}: Rendering proposals. Total count: ${this.proposalData.length}, Current page: ${this.currentPage + 1}`); 58 | 59 | const content = document.createElement("div"); 60 | content.className = "proposal-content"; 61 | 62 | // Use effective page size if it was calculated, otherwise use config value 63 | const proposalsPerPage = this.effectivePageSize || this.config.proposalsPerPage; 64 | 65 | // Calculate page boundaries 66 | const pageCount = Math.ceil(this.proposalData.length / proposalsPerPage); 67 | const startIndex = this.currentPage * proposalsPerPage; 68 | const endIndex = Math.min(startIndex + proposalsPerPage, this.proposalData.length); 69 | 70 | Log.debug(`${this.name}: Page calculations: pageCount=${pageCount}, startIndex=${startIndex}, endIndex=${endIndex}, proposalsPerPage=${proposalsPerPage}`); 71 | 72 | // Display current page of proposals 73 | this.proposalData.slice(startIndex, endIndex).forEach(proposal => { 74 | const proposalContainer = document.createElement("div"); 75 | proposalContainer.className = "proposal-container"; 76 | 77 | // Add header with ID, proposer and status 78 | const headerInfo = document.createElement("div"); 79 | headerInfo.className = "proposal-header"; 80 | 81 | // Create the ID span 82 | const idSpan = document.createElement("span"); 83 | idSpan.className = "prop-id"; 84 | 85 | // Add proposer info if configured and available 86 | if (this.config.showProposer && proposal.proposerDisplay) { 87 | idSpan.textContent = `Prop ${proposal.proposalId} | ${proposal.proposerDisplay}`; 88 | } else { 89 | idSpan.textContent = `Prop ${proposal.proposalId}`; 90 | } 91 | 92 | const statusSpan = document.createElement("span"); 93 | statusSpan.className = `status-badge ${proposal.status.currentStatus.toLowerCase()}`; 94 | statusSpan.textContent = proposal.status.currentStatus; 95 | 96 | headerInfo.appendChild(idSpan); 97 | headerInfo.appendChild(statusSpan); 98 | proposalContainer.appendChild(headerInfo); 99 | 100 | // Add title 101 | const titleInfo = document.createElement("div"); 102 | titleInfo.className = "proposal-title"; 103 | titleInfo.textContent = proposal.title; 104 | proposalContainer.appendChild(titleInfo); 105 | 106 | // Create vote progress bar container 107 | const progressContainer = document.createElement("div"); 108 | progressContainer.className = "vote-progress-container"; 109 | 110 | // Calculate total votes and percentages 111 | const totalVotes = proposal.forVotes + proposal.againstVotes + proposal.abstainVotes; 112 | const forPercent = totalVotes > 0 ? (proposal.forVotes / totalVotes) * 100 : 0; 113 | const againstPercent = totalVotes > 0 ? (proposal.againstVotes / totalVotes) * 100 : 0; 114 | const abstainPercent = totalVotes > 0 ? (proposal.abstainVotes / totalVotes) * 100 : 0; 115 | 116 | // Create vote summary text 117 | const voteSummary = document.createElement("div"); 118 | voteSummary.className = "vote-summary"; 119 | voteSummary.innerHTML = `For ${proposal.forVotes} · Abstain ${proposal.abstainVotes} · Against ${proposal.againstVotes}`; 120 | 121 | // Create progress bar 122 | const progressBar = document.createElement("div"); 123 | progressBar.className = "vote-progress-bar"; 124 | 125 | // Add segments for each vote type 126 | const forSegment = document.createElement("div"); 127 | forSegment.className = "vote-segment for"; 128 | forSegment.style.width = `${forPercent}%`; 129 | 130 | const abstainSegment = document.createElement("div"); 131 | abstainSegment.className = "vote-segment abstain"; 132 | abstainSegment.style.width = `${abstainPercent}%`; 133 | 134 | const againstSegment = document.createElement("div"); 135 | againstSegment.className = "vote-segment against"; 136 | againstSegment.style.width = `${againstPercent}%`; 137 | 138 | // Add quorum indicator if available 139 | if (proposal.quorumVotes) { 140 | const quorumPercent = (proposal.quorumVotes / totalVotes) * 100; 141 | const quorumIndicator = document.createElement("div"); 142 | quorumIndicator.className = "quorum-indicator"; 143 | quorumIndicator.style.left = `${quorumPercent}%`; 144 | progressBar.appendChild(quorumIndicator); 145 | } 146 | 147 | progressBar.appendChild(forSegment); 148 | progressBar.appendChild(abstainSegment); 149 | progressBar.appendChild(againstSegment); 150 | 151 | progressContainer.appendChild(voteSummary); 152 | progressContainer.appendChild(progressBar); 153 | proposalContainer.appendChild(progressContainer); 154 | 155 | content.appendChild(proposalContainer); 156 | }); 157 | 158 | wrapper.appendChild(content); 159 | 160 | // Add pagination indicator if there are multiple pages 161 | if (pageCount > 1) { 162 | const paginationDiv = document.createElement("div"); 163 | paginationDiv.className = "pagination"; 164 | paginationDiv.innerHTML = `Page ${this.currentPage + 1}/${pageCount}`; 165 | wrapper.appendChild(paginationDiv); 166 | } 167 | } 168 | 169 | return wrapper; 170 | }, 171 | 172 | getStyles: function() { 173 | return [ 174 | "MMM-NounsProposal.css", 175 | ]; 176 | }, 177 | 178 | scheduleUpdate: function() { 179 | const self = this; 180 | 181 | // Initial fetch 182 | self.fetchProposalData(); 183 | 184 | // Schedule data updates 185 | setInterval(function() { 186 | self.fetchProposalData(); 187 | }, this.config.updateInterval); 188 | }, 189 | 190 | cyclePagination: function() { 191 | const self = this; 192 | 193 | // Clear any existing timer 194 | if (this.cycleTimer) { 195 | clearInterval(this.cycleTimer); 196 | this.cycleTimer = null; 197 | } 198 | 199 | // If we have proposals data 200 | if (this.proposalData && Array.isArray(this.proposalData) && this.proposalData.length > 0) { 201 | Log.info(`${this.name}: Starting page cycling for ${this.proposalData.length} proposals`); 202 | 203 | // Determine the best page size based on available data 204 | let effectivePageSize = this.config.proposalsPerPage; 205 | 206 | // If we have fewer proposals than would make two full pages, adjust page size 207 | if (this.proposalData.length < this.config.proposalsPerPage * 2 && 208 | this.proposalData.length > this.config.minProposalsPerPage) { 209 | // Calculate a new smaller page size to enable cycling 210 | effectivePageSize = Math.floor(this.proposalData.length / 2); 211 | if (effectivePageSize < this.config.minProposalsPerPage) { 212 | effectivePageSize = this.config.minProposalsPerPage; 213 | } 214 | Log.info(`${this.name}: Adjusted page size to ${effectivePageSize} for better cycling`); 215 | } 216 | 217 | // Calculate how many pages we'll have with the effective page size 218 | const pageCount = Math.ceil(this.proposalData.length / effectivePageSize); 219 | 220 | // Store the effective page size for use in getDom 221 | this.effectivePageSize = effectivePageSize; 222 | 223 | // Only set up cycling if we can have multiple pages 224 | if (pageCount > 1) { 225 | // Set up interval for cycling pages 226 | this.cycleTimer = setInterval(function() { 227 | // Move to next page, wrapping around to beginning after last page 228 | self.currentPage = (self.currentPage + 1) % pageCount; 229 | Log.debug(`${self.name}: Cycling to page ${self.currentPage + 1}/${pageCount}`); 230 | self.updateDom(self.config.animationSpeed); 231 | }, this.config.cycleInterval); 232 | } else { 233 | Log.info(`${this.name}: Not enough proposals for cycling with adjusted page size, staying on page 1`); 234 | } 235 | } else { 236 | Log.info(`${this.name}: No proposals available for cycling`); 237 | } 238 | }, 239 | 240 | fetchProposalData: function() { 241 | this.sendSocketNotification("FETCH_PROPOSAL_DATA", { 242 | maxProposals: this.config.maxProposals 243 | }); 244 | }, 245 | 246 | socketNotificationReceived: function(notification, payload) { 247 | if (notification === "PROPOSAL_DATA_RESULT") { 248 | if (payload.error) { 249 | this.error = payload.error; 250 | Log.error(`${this.name}: Error receiving data: ${payload.error}`); 251 | } else { 252 | Log.info(`${this.name}: Received ${payload.data.length} proposals`); 253 | this.proposalData = payload.data; 254 | this.error = null; 255 | 256 | // Reset to first page when new data arrives 257 | this.currentPage = 0; 258 | 259 | // Start/restart cycling timer with new data 260 | this.cyclePagination(); 261 | } 262 | 263 | this.loaded = true; 264 | this.updateDom(this.config.animationSpeed); 265 | } 266 | } 267 | }); 268 | --------------------------------------------------------------------------------