Skip to content

Commit

Permalink
fix: use method of largest remainder to count poll votes
Browse files Browse the repository at this point in the history
Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
  • Loading branch information
Antreesy committed Oct 14, 2024
1 parent 2fa9f94 commit cbcb05a
Show file tree
Hide file tree
Showing 3 changed files with 114 additions and 9 deletions.
17 changes: 8 additions & 9 deletions src/components/PollViewer/PollViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
<div class="results__option-title">
<p>{{ option }}</p>
<p class="percentage">
{{ getVotePercentage(index) + '%' }}
{{ votePercentage[index] + '%' }}
</p>
</div>
<div v-if="getFilteredDetails(index).length > 0 || selfHasVotedOption(index)"
Expand All @@ -52,7 +52,7 @@
</p>
</div>
<NcProgressBar class="results__option-progress"
:value="getVotePercentage(index)"
:value="votePercentage[index]"
size="medium" />
</div>
</div>
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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)
},
},
}
</script>
Expand Down
39 changes: 39 additions & 0 deletions src/utils/__tests__/countPollVotes.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
67 changes: 67 additions & 0 deletions src/utils/countPollVotes.ts
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit cbcb05a

Please sign in to comment.