diff --git a/src/components/PollViewer/PollViewer.vue b/src/components/PollViewer/PollViewer.vue
index 2085a089050..c5cdd9aed7e 100644
--- a/src/components/PollViewer/PollViewer.vue
+++ b/src/components/PollViewer/PollViewer.vue
@@ -39,7 +39,7 @@
{{ option }}
- {{ getVotePercentage(index) + '%' }}
+ {{ votePercentage[index] + '%' }}
@@ -109,6 +109,7 @@ import { useIsInCall } from '../../composables/useIsInCall.js'
import { POLL } from '../../constants.js'
import { EventBus } from '../../services/EventBus.js'
import { usePollsStore } from '../../stores/polls.ts'
+import { countPollVotes } from '../../utils/countPollVotes.ts'
export default {
name: 'PollViewer',
@@ -222,6 +223,11 @@ export default {
canEndPoll() {
return this.isPollOpen && this.selfIsOwnerOrModerator
},
+
+ votePercentage() {
+ const votes = Object.keys(Object(this.poll?.options)).map(index => this.poll?.votes['option-' + index] ?? 0)
+ return countPollVotes(votes, this.poll.numVoters)
+ },
},
watch: {
@@ -331,13 +337,6 @@ export default {
getFilteredDetails(index) {
return (this.poll?.details || []).filter(item => item.optionId === index)
},
-
- getVotePercentage(index) {
- if (!this.poll?.votes['option-' + index] || !this.poll?.numVoters) {
- return 0
- }
- return parseInt(this.poll?.votes['option-' + index] / this.poll?.numVoters * 100)
- },
},
}
diff --git a/src/utils/__tests__/countPollVotes.spec.js b/src/utils/__tests__/countPollVotes.spec.js
new file mode 100644
index 00000000000..def71cdf27c
--- /dev/null
+++ b/src/utils/__tests__/countPollVotes.spec.js
@@ -0,0 +1,39 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { countPollVotes } from '../countPollVotes.ts'
+
+describe('countPollVotes', () => {
+ const tests = [
+ [0, [], 0],
+ [1, [1], 100],
+ // Math rounded to 100%
+ [4, [1, 3], 100],
+ [11, [1, 2, 8], 100],
+ [13, [11, 2], 100],
+ [13, [9, 4], 100],
+ [26, [16, 5, 5], 100],
+ // Rounded to 100% by largest remainder
+ [1000, [132, 494, 92, 282], 100],
+ [1000, [135, 480, 97, 288], 100],
+ // Best effort is 99%
+ [3, [1, 1, 1], 99],
+ [7, [2, 2, 3], 99],
+ [1000, [133, 491, 93, 283], 99],
+ [1000, [134, 488, 94, 284], 99],
+ // Best effort is 98%
+ [1000, [136, 482, 96, 286], 98],
+ [1000, [135, 140, 345, 95, 285], 98],
+ // Best effort is 97%
+ [1000, [137, 132, 347, 97, 287], 97],
+ ]
+
+ it.each(tests)('test %d votes in %o distribution rounds to %d%%', (total, votes, result) => {
+ const percentageMap = countPollVotes(votes, total)
+
+ expect(votes.reduce((a, b) => a + b, 0)).toBe(total)
+ expect(percentageMap.reduce((a, b) => a + b, 0)).toBe(result)
+ })
+})
diff --git a/src/utils/countPollVotes.ts b/src/utils/countPollVotes.ts
new file mode 100644
index 00000000000..b7c508e9d56
--- /dev/null
+++ b/src/utils/countPollVotes.ts
@@ -0,0 +1,67 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+type VoteAccumulator = { rounded: number[], wholes: number[], remainders: number[] }
+
+/**
+ * Finds indexes of largest remainders to distribute quota
+ * @param array array of numbers to compare
+ */
+function getLargestIndexes(array: number[]) {
+ let maxValue = 0
+ const maxIndexes: number[] = []
+
+ for (let i = 0; i < array.length; i++) {
+ if (array[i] > maxValue) {
+ maxValue = array[i]
+ maxIndexes.length = 0
+ maxIndexes.push(i)
+ } else if (array[i] === maxValue) {
+ maxIndexes.push(i)
+ }
+ }
+
+ return maxIndexes
+}
+
+/**
+ * Provide percentage distribution closest to 100 by method of largest remainder
+ * @param votes array of given votes
+ * @param total amount of votes
+ */
+export function countPollVotes(votes: number[], total: number) {
+ if (!total) {
+ return votes
+ }
+
+ const { rounded, wholes, remainders } = votes.reduce((acc: VoteAccumulator, vote, idx) => {
+ const quota = vote / total * 100
+ acc.rounded[idx] = Math.round(quota)
+ acc.wholes[idx] = Math.floor(quota)
+ acc.remainders[idx] = Math.round((quota - Math.floor(quota)) * 1000)
+ return acc
+ }, { rounded: [], wholes: [], remainders: [] })
+
+ // Check if simple round gives 100%
+ if (rounded.reduce((acc, value) => acc + value) === 100) {
+ return rounded
+ }
+
+ // Increase values by largest remainder method if difference allows
+ for (let i = 100 - wholes.reduce((acc, value) => acc + value); i > 0;) {
+ const largest = getLargestIndexes(remainders)
+ if (largest.length > i) {
+ return wholes
+ }
+
+ for (const idx of largest) {
+ wholes[idx]++
+ remainders[idx] = 0
+ i--
+ }
+ }
+
+ return wholes
+}