1
- import { setOf } from '@seedcompany/common' ;
1
+ import { cmpBy , groupBy , setOf } from '@seedcompany/common' ;
2
+ import { type Simplify } from 'type-fest' ;
2
3
3
- export class PollResults < T > {
4
- constructor ( protected readonly data : PollData < T > ) { }
5
-
6
- /** Returns true if there were any votes */
7
- get anyVotes ( ) {
8
- return this . numberOfVotes > 0 ;
9
- }
4
+ class PollState < Choice , Voter > {
5
+ closed = false ;
6
+ voters = new Set < Voter > ( ) ;
7
+ votes = new Map < Voter , Choice > ( ) ;
8
+ vetoers = new Set < Voter > ( ) ;
9
+ }
10
10
11
- /** Returns true if there were no votes */
12
- get noVotes ( ) {
13
- return this . numberOfVotes === 0 ;
14
- }
11
+ class VoteTally < Choice , Voter > {
12
+ protected readonly state : PollState < Choice , Voter > ;
13
+ protected readonly tallies : ReadonlyArray <
14
+ Readonly < {
15
+ choice : Choice ;
16
+ count : number ;
17
+ voters : ReadonlySet < Voter > ;
18
+ } >
19
+ > ;
15
20
16
- get numberOfVotes ( ) {
17
- return [ ...this . data . votes . values ( ) ] . reduce ( ( total , cur ) => total + cur , 0 ) ;
21
+ constructor ( state : PollState < Choice , Voter > ) {
22
+ this . state = state ;
23
+ this . tallies = groupBy ( state . votes , ( [ , vote ] ) => vote )
24
+ . map ( ( entries ) => {
25
+ const voters = setOf ( entries . map ( ( [ voter ] ) => voter ) ) ;
26
+ return {
27
+ choice : entries [ 0 ] [ 1 ] ,
28
+ voters,
29
+ count : voters . size ,
30
+ } ;
31
+ } )
32
+ . sort ( cmpBy ( [ ( c ) => c . count , 'desc' ] ) ) ;
18
33
}
19
34
20
- get vetoed ( ) {
21
- return this . data . vetoed ;
35
+ protected get totalVotes ( ) {
36
+ return this . state . votes . size ;
22
37
}
23
38
24
- /** Returns if there was a tie for the highest votes */
25
- get tie ( ) {
26
- const [ highest , second ] = this . sorted ;
27
- return highest && second ? highest [ 1 ] === second [ 1 ] : false ;
39
+ protected get vetoed ( ) {
40
+ return this . state . vetoers . size > 0 ;
28
41
}
29
42
30
43
/** Returns the largest minority vote (could be majority too), if there was one */
31
44
get plurality ( ) {
32
- const [ highest , second ] = this . sorted ;
45
+ if ( this . vetoed ) {
46
+ return undefined ;
47
+ }
48
+ const [ highest , second ] = this . tallies ;
33
49
if ( ! highest ) {
34
50
return undefined ;
35
51
}
36
- return highest [ 1 ] > ( second ?. [ 1 ] ?? 0 ) ? highest [ 0 ] : undefined ;
52
+ return highest . count > ( second ?. count ?? 0 ) ? highest : undefined ;
37
53
}
38
54
39
- /** Returns the majority vote (>50%), if there was one */
55
+ /** Returns the majority vote (>50%) if there was one */
40
56
get majority ( ) {
41
- const [ first ] = this . sorted ;
57
+ if ( this . vetoed ) {
58
+ return undefined ;
59
+ }
60
+ const [ first ] = this . tallies ;
42
61
if ( ! first ) {
43
62
return undefined ;
44
63
}
45
- return first [ 1 ] > this . numberOfVotes / 2 ? first [ 0 ] : undefined ;
64
+ return first . count > this . totalVotes / 2 ? first : undefined ;
46
65
}
47
66
48
- /** Returns the unanimous vote, if there was one */
67
+ /** Returns the unanimous vote if there was one */
49
68
get unanimous ( ) {
50
- const all = this . sorted ;
51
- return all . length === 1 ? all [ 0 ] ! [ 0 ] : undefined ;
69
+ if ( this . vetoed ) {
70
+ return undefined ;
71
+ }
72
+ const all = this . tallies ;
73
+ return all . length === 1 ? all [ 0 ] ! : undefined ;
74
+ }
75
+ }
76
+
77
+ export type WinnerStrategy = keyof Simplify < VoteTally < any , any > > ;
78
+
79
+ export class PollResult < Choice , Voter = unknown > extends VoteTally <
80
+ Choice ,
81
+ Voter
82
+ > {
83
+ constructor (
84
+ state : PollState < Choice , Voter > ,
85
+ readonly winnerStrategy : WinnerStrategy ,
86
+ ) {
87
+ super ( state ) ;
52
88
}
53
89
54
- /** Returns all votes sorted by most voted first (ties are unaccounted for) */
55
- get allVotes ( ) {
56
- return setOf ( this . sorted . map ( ( [ vote ] ) => vote ) ) ;
90
+ /** Returns the winning choice (if there is one), based on the chosen strategy */
91
+ get winner ( ) {
92
+ return this . winnerTally ?. choice ;
57
93
}
58
94
59
- private get sorted ( ) {
60
- return [ ...this . data . votes ] . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) ;
95
+ /** Returns the winner tally (if there is one), based on the chosen strategy */
96
+ get winnerTally ( ) {
97
+ return this [ this . winnerStrategy ] ;
98
+ }
99
+
100
+ /** Returns true if there were any votes */
101
+ get anyVotes ( ) {
102
+ return this . totalVotes > 0 ;
103
+ }
104
+
105
+ /** Returns true if there were no votes */
106
+ get noVotes ( ) {
107
+ return this . totalVotes === 0 ;
108
+ }
109
+
110
+ get totalVotes ( ) {
111
+ return super . totalVotes ;
112
+ }
113
+
114
+ get vetoed ( ) {
115
+ return super . vetoed ;
116
+ }
117
+
118
+ get vetoers ( ) : ReadonlySet < Voter > {
119
+ return this . state . vetoers ;
120
+ }
121
+
122
+ /** Returns if there was a tie in the tally of the top choices */
123
+ get tie ( ) {
124
+ const [ highest , second ] = this . tallies ;
125
+ return highest && second ? highest . count === second . count : false ;
126
+ }
127
+
128
+ /** Returns all tallies sorted by the highest choice first (ties are unaccounted for) */
129
+ get allTallies ( ) {
130
+ return this . tallies ;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * A collection of ballots.
136
+ */
137
+ export class BallotBox < Choice , Voter = unknown > {
138
+ /** @internal */
139
+ constructor ( protected readonly state : PollState < Choice , Voter > ) { }
140
+
141
+ get isClosed ( ) {
142
+ return this . state . closed ;
143
+ }
144
+
145
+ /**
146
+ * Register a new voter.
147
+ * Voters can only be registered once.
148
+ */
149
+ registerVoter ( voter : Voter ) {
150
+ if ( this . state . closed ) {
151
+ throw new Error ( 'Poll is closed' ) ;
152
+ }
153
+ if ( this . state . voters . has ( voter ) ) {
154
+ throw new Error ( 'Voter already exists' ) ;
155
+ }
156
+ this . state . voters . add ( voter ) ;
157
+ return new Ballot ( voter , this . state ) ;
158
+ }
159
+
160
+ /**
161
+ * Cast a vote as a certain voter.
162
+ *
163
+ * This can only be done once, per voter, even if the voter identity is the same.
164
+ * If you need to redo a vote, you can use {@link registerVoter} to hold the ballot handle,
165
+ * and call its {@link vote} multiple times (as long as the {@link Poll} is not closed).
166
+ *
167
+ * @see Ballot.vote
168
+ */
169
+ vote ( voter : Voter , choice : Choice ) {
170
+ this . registerVoter ( voter ) . vote ( choice ) ;
171
+ }
172
+
173
+ /**
174
+ * Veto the poll as a certain voter.
175
+ *
176
+ * This can only be done once, per voter, even if the voter identity is the same.
177
+ *
178
+ * @see Ballot.veto
179
+ */
180
+ veto ( voter : Voter ) {
181
+ this . registerVoter ( voter ) . veto ( ) ;
61
182
}
62
183
}
63
184
64
185
/**
65
186
* @example
66
187
* const poll = new Poll();
67
188
*
68
- * poll.noVotes; // true
69
- * poll.vote(true);
70
- * poll.unanimous; // true
71
- * poll.anyVotes; // true
72
- *
73
- * poll.vote(false);
74
- * poll.unanimous; // undefined
75
- * poll.tie; // true
76
- * poll.majority; // undefined
77
- * poll.plurality; // undefined
189
+ * poll.vote('Bilbo', true);
190
+ * poll.vote('Frodo', true);
191
+ * poll.vote('Samwise', false);
78
192
*
79
- * poll.vote(true );
80
- * poll.majority ; // true
81
- * poll.plurality ; // true
193
+ * const result = poll.close( );
194
+ * result.winner.choice ; // true
195
+ * result.winner.voters ; // {Bilbo, Frodo}
82
196
*/
83
- export class Poll < T = boolean > extends PollResults < T > implements PollVoter < T > {
84
- // Get a view of this poll, with results hidden.
85
- readonly voter : PollVoter < T > = this ;
86
- // Get a readonly view of this poll's results.
87
- readonly results : PollResults < T > = this ;
197
+ export class Poll < Choice , Voter = unknown > extends BallotBox < Choice , Voter > {
198
+ /**
199
+ * Configures a poll type, returning a creation handle, and pre-configured types
200
+ * for the polling system.
201
+ *
202
+ * @template Choice - The type representing the choices available in the poll.
203
+ * @template Voter - The type representing the voter, defaults to unknown if not specified.
204
+ */
205
+ static configured < Choice , Voter = unknown > ( ) {
206
+ return {
207
+ create : ( ) => new Poll < Choice , Voter > ( ) ,
208
+ } as unknown as {
209
+ create : ( ) => Poll < Choice , Voter > ;
210
+ $Poll : Poll < Choice , Voter > ;
211
+ $BallotBox : BallotBox < Choice , Voter > ;
212
+ $Ballot : Ballot < Choice , Voter > ;
213
+ $Result : PollResult < Choice , Voter > ;
214
+ } ;
215
+ }
88
216
89
217
constructor ( ) {
90
- super ( new PollData < T > ( ) ) ;
218
+ super ( new PollState < Choice , Voter > ( ) ) ;
91
219
}
92
220
93
- vote ( vote : T ) {
94
- this . data . votes . set ( vote , ( this . data . votes . get ( vote ) ?? 0 ) + 1 ) ;
221
+ readonly ballotBox = new BallotBox ( this . state ) ;
222
+
223
+ get voters ( ) : ReadonlySet < Voter > {
224
+ return this . state . voters ;
95
225
}
96
226
97
- veto ( ) {
98
- this . data . vetoed = true ;
227
+ /**
228
+ * Close the poll and return the result.
229
+ * This can only be done once.
230
+ */
231
+ close ( winnerStrategy : WinnerStrategy = 'plurality' ) {
232
+ if ( this . state . closed ) {
233
+ throw new Error ( 'Poll is already closed' ) ;
234
+ }
235
+ this . state . closed = true ;
236
+ const result = new PollResult ( this . state , winnerStrategy ) ;
237
+ return result ;
99
238
}
100
239
}
101
240
102
241
/**
103
- * The mutations available for a poll.
242
+ * A handle for a certain poll voter to cast their vote .
104
243
*/
105
- export abstract class PollVoter < T > {
244
+ export class Ballot < Choice , Voter = unknown > {
245
+ /** @internal */
246
+ constructor (
247
+ readonly voter : Voter ,
248
+ protected readonly state : PollState < Choice , Voter > ,
249
+ ) { }
250
+
106
251
/** Cast a vote. */
107
- abstract vote ( vote : T ) : void ;
252
+ vote ( choice : Choice ) {
253
+ if ( this . state . closed ) {
254
+ throw new Error ( 'Poll is closed' ) ;
255
+ }
256
+ this . state . votes . set ( this . voter , choice ) ;
257
+ }
108
258
109
259
/**
110
260
* Veto the poll all together.
@@ -113,12 +263,12 @@ export abstract class PollVoter<T> {
113
263
* "cancel" the poll / override all other votes.
114
264
* Exceptions are for unexpected errors, where this veto would be a logical
115
265
* expectation, so throwing is not the best way to handle it.
116
- * This could be enhanced in future to allow a reason for the veto.
266
+ * This could be enhanced in the future to allow a reason for the veto.
117
267
*/
118
- abstract veto ( ) : void ;
119
- }
120
-
121
- class PollData < T > {
122
- votes = new Map < T , number > ( ) ;
123
- vetoed = false ;
268
+ veto ( ) {
269
+ if ( this . state . closed ) {
270
+ throw new Error ( 'Poll is closed' ) ;
271
+ }
272
+ this . state . vetoers . add ( this . voter ) ;
273
+ }
124
274
}
0 commit comments