OpenSlides/client/src/app/site/assignments/services/assignment-poll.service.ts

309 lines
11 KiB
TypeScript
Raw Normal View History

2019-03-04 18:28:21 +01:00
import { Injectable } from '@angular/core';
import { ConfigService } from 'app/core/ui-services/config.service';
import {
CalculablePollKey,
MajorityMethod,
PollMajorityMethod,
PollService,
2019-03-04 18:28:21 +01:00
PollVoteValue
} from 'app/core/ui-services/poll.service';
import { AssignmentOptionVote } from 'app/shared/models/assignments/assignment-poll-option';
import { ViewAssignmentPoll } from '../models/view-assignment-poll';
import { ViewAssignmentPollOption } from '../models/view-assignment-poll-option';
2019-03-04 18:28:21 +01:00
type AssignmentPollValues = 'auto' | 'votes' | 'yesnoabstain' | 'yesno';
export type AssignmentPollMethod = 'yn' | 'yna' | 'votes';
2019-04-18 17:09:59 +02:00
export type AssignmentPercentBase = 'YES_NO_ABSTAIN' | 'YES_NO' | 'VALID' | 'CAST' | 'DISABLED';
2019-03-04 18:28:21 +01:00
2019-04-26 18:47:47 +02:00
/**
* interface common to data in a ViewAssignmentPoll and PollSlideData
*
* TODO: simplify
*/
export interface CalculationData {
pollMethod: AssignmentPollMethod;
votesno: number;
votesabstain: number;
votescast: number;
votesvalid: number;
votesinvalid: number;
percentBase?: AssignmentPercentBase;
pollOptions?: {
votes: AssignmentOptionVote[];
}[];
}
interface CalculationOption {
votes: AssignmentOptionVote[];
}
2019-04-25 15:34:42 +02:00
/**
* Vote entries included once for summary (e.g. total votes cast)
*/
export type SummaryPollKey = 'votescast' | 'votesvalid' | 'votesinvalid' | 'votesno' | 'votesabstain';
2019-03-04 18:28:21 +01:00
/**
* Service class for assignment polls.
2019-03-04 18:28:21 +01:00
*/
@Injectable({
providedIn: 'root'
})
export class AssignmentPollService extends PollService {
/**
* list of poll keys that are numbers and can be part of a quorum calculation
*/
public pollValues: CalculablePollKey[] = ['votesno', 'votesabstain', 'votesvalid', 'votesinvalid', 'votescast'];
2019-03-04 18:28:21 +01:00
/**
* the method used for polls (as per config)
*/
public pollMethod: AssignmentPollValues;
/**
* the method used to determine the '100%' base (set in config)
*/
public percentBase: AssignmentPercentBase;
/**
* convenience function for displaying the available majorities
*/
public get majorityMethods(): MajorityMethod[] {
return PollMajorityMethod;
}
/**
* Constructor. Subscribes to the configuration values needed
*
2019-03-04 18:28:21 +01:00
* @param config ConfigService
*/
public constructor(config: ConfigService) {
super();
config
.get<string>('assignments_poll_default_majority_method')
.subscribe(method => (this.defaultMajorityMethod = method));
config
.get<AssignmentPollValues>('assignments_poll_vote_values')
.subscribe(method => (this.pollMethod = method));
config
.get<AssignmentPercentBase>('assignments_poll_100_percent_base')
.subscribe(base => (this.percentBase = base));
}
public getVoteOptionsByPoll(poll: ViewAssignmentPoll): CalculablePollKey[] {
return this.pollValues.filter(name => poll[name] !== undefined);
}
2019-03-04 18:28:21 +01:00
/**
* Get the base amount for the 100% calculations. Note that some poll methods
* (e.g. yes/no/abstain may have a different percentage base and will return null here)
2019-03-04 18:28:21 +01:00
*
2019-04-26 18:47:47 +02:00
* @param data
2019-03-04 18:28:21 +01:00
* @returns The amount of votes indicating the 100% base
*/
2019-04-26 18:47:47 +02:00
public getBaseAmount(data: CalculationData): number | null {
const percentBase = data.percentBase || this.percentBase;
switch (percentBase) {
2019-03-04 18:28:21 +01:00
case 'DISABLED':
return null;
case 'YES_NO':
case 'YES_NO_ABSTAIN':
2019-04-26 18:47:47 +02:00
if (data.pollMethod === 'votes') {
const yes = data.pollOptions.map(option => {
2019-04-18 17:09:59 +02:00
const yesValue = option.votes.find(v => v.value === 'Votes');
2019-03-04 18:28:21 +01:00
return yesValue ? yesValue.weight : -99;
});
if (Math.min(...yes) < 0) {
return null;
} else {
// TODO: Counting 'No (and possibly 'Abstain') here?
2019-03-04 18:28:21 +01:00
return yes.reduce((a, b) => a + b);
}
} else {
return null;
}
case 'CAST':
2019-04-26 18:47:47 +02:00
return data.votescast > 0 && data.votesinvalid >= 0 ? data.votescast : null;
case 'VALID':
2019-04-26 18:47:47 +02:00
return data.votesvalid > 0 ? data.votesvalid : null;
2019-03-04 18:28:21 +01:00
default:
return null;
}
}
/**
* Get the percentage for an option
2019-03-04 18:28:21 +01:00
*
* @param poll
2019-04-26 18:47:47 +02:00
* @param data
2019-03-04 18:28:21 +01:00
* @returns a percentage number with two digits, null if the value cannot be calculated
*/
2019-04-26 18:47:47 +02:00
public getPercent(data: CalculationData, option: CalculationOption, key: PollVoteValue): number | null {
const percentBase = data.percentBase || this.percentBase;
2019-04-18 17:09:59 +02:00
let base = 0;
2019-04-26 18:47:47 +02:00
if (percentBase === 'DISABLED') {
2019-04-18 17:09:59 +02:00
return null;
2019-04-26 18:47:47 +02:00
} else if (percentBase === 'VALID') {
base = data.votesvalid;
} else if (percentBase === 'CAST') {
base = data.votescast;
2019-04-18 17:09:59 +02:00
} else {
2019-04-26 18:47:47 +02:00
base = data.pollMethod === 'votes' ? this.getBaseAmount(data) : this.getOptionBaseAmount(data, option);
2019-04-18 17:09:59 +02:00
}
if (!base || base < 0) {
2019-03-04 18:28:21 +01:00
return null;
}
2019-04-26 18:47:47 +02:00
const vote = option.votes.find(v => v.value === key);
2019-03-04 18:28:21 +01:00
if (!vote) {
return null;
}
return Math.round(((vote.weight * 100) / base) * 100) / 100;
}
/**
* get the percentage for a non-abstract per-poll value
* TODO: similar code to getPercent. Mergeable?
*
2019-04-26 18:47:47 +02:00
* @param data
* @param value a per-poll value (e.g. 'votesvalid')
* @returns a percentage number with two digits, null if the value cannot be calculated
*/
2019-04-26 18:47:47 +02:00
public getValuePercent(data: CalculationData, value: CalculablePollKey): number | null {
const percentBase = data.percentBase || this.percentBase;
switch (percentBase) {
case 'YES_NO':
case 'YES_NO_ABSTAIN':
case 'DISABLED':
return null;
case 'VALID':
if (value === 'votesinvalid' || value === 'votescast') {
return null;
}
break;
}
const baseAmount = this.getBaseAmount(data);
if (!baseAmount) {
return null;
}
2019-04-26 18:47:47 +02:00
const amount = data[value];
if (amount === undefined || amount < 0) {
return null;
}
2019-04-26 18:47:47 +02:00
return Math.round(((amount * 100) / baseAmount) * 100) / 100;
}
2019-03-04 18:28:21 +01:00
/**
* Check if the option in a poll is abstract (percentages should not be calculated)
*
2019-04-26 18:47:47 +02:00
* @param data
* @param option
2019-04-18 17:09:59 +02:00
* @param key (optional) the key to calculate
2019-03-04 18:28:21 +01:00
* @returns true if the poll has no percentages, the poll option is a special value,
* or if the calculations are disabled in the config
*/
2019-04-26 18:47:47 +02:00
public isAbstractOption(data: CalculationData, option: ViewAssignmentPollOption, key?: PollVoteValue): boolean {
const percentBase = data.percentBase || this.percentBase;
if (percentBase === 'DISABLED' || !option.votes || !option.votes.length) {
2019-04-18 17:09:59 +02:00
return true;
}
2019-04-26 18:47:47 +02:00
if (key === 'Abstain' && percentBase === 'YES_NO') {
2019-03-04 18:28:21 +01:00
return true;
}
2019-04-26 18:47:47 +02:00
if (data.pollMethod === 'votes') {
return this.getBaseAmount(data) > 0 ? false : true;
2019-03-04 18:28:21 +01:00
} else {
return option.votes.some(v => v.weight < 0);
}
}
/**
* Check for abstract (not usable as percentage) options in non-option
* 'meta' values
*
2019-04-26 18:47:47 +02:00
* @param data
2019-03-04 18:28:21 +01:00
* @param value
* @returns true if percentages cannot be calculated
* TODO: Yes, No, etc. in an option will always return true.
* Use {@link isAbstractOption} for these
*/
2019-04-26 18:47:47 +02:00
public isAbstractValue(data: CalculationData, value: CalculablePollKey): boolean {
const percentBase = data.percentBase || this.percentBase;
if (percentBase === 'DISABLED' || !this.getBaseAmount(data) || !this.pollValues.includes(value)) {
2019-03-04 18:28:21 +01:00
return true;
}
2019-04-26 18:47:47 +02:00
if (percentBase === 'CAST' && data[value] >= 0) {
2019-03-04 18:28:21 +01:00
return false;
2019-04-26 18:47:47 +02:00
} else if (percentBase === 'VALID' && value === 'votesvalid' && data[value] > 0) {
2019-03-04 18:28:21 +01:00
return false;
}
return true;
}
/**
* Calculate the base amount inside an option. Only useful if poll method is not 'votes'
*
2019-04-26 18:47:47 +02:00
* @param data
* @param option
2019-03-04 18:28:21 +01:00
* @returns an positive integer to be used as percentage base, or null
*/
2019-04-26 18:47:47 +02:00
private getOptionBaseAmount(data: CalculationData, option: CalculationOption): number | null {
const percentBase = data.percentBase || this.percentBase;
if (percentBase === 'DISABLED' || data.pollMethod === 'votes') {
2019-03-04 18:28:21 +01:00
return null;
2019-04-26 18:47:47 +02:00
} else if (percentBase === 'CAST') {
return data.votescast > 0 ? data.votescast : null;
} else if (percentBase === 'VALID') {
return data.votesvalid > 0 ? data.votesvalid : null;
2019-03-04 18:28:21 +01:00
}
const yes = option.votes.find(v => v.value === 'Yes');
const no = option.votes.find(v => v.value === 'No');
2019-04-26 18:47:47 +02:00
if (percentBase === 'YES_NO') {
2019-03-04 18:28:21 +01:00
if (!yes || yes.weight === undefined || !no || no.weight === undefined) {
return null;
}
return yes.weight >= 0 && no.weight >= 0 ? yes.weight + no.weight : null;
2019-04-26 18:47:47 +02:00
} else if (percentBase === 'YES_NO_ABSTAIN') {
2019-03-04 18:28:21 +01:00
const abstain = option.votes.find(v => v.value === 'Abstain');
if (!abstain || abstain.weight === undefined) {
return null;
}
return yes.weight >= 0 && no.weight >= 0 && abstain.weight >= 0
? yes.weight + no.weight + abstain.weight
: null;
}
}
/**
* Get the minimum amount of votes needed for an option to pass the quorum
*
* @param method
2019-04-26 18:47:47 +02:00
* @param data
2019-03-04 18:28:21 +01:00
* @param option
* @returns a positive integer number; may return null if quorum is not calculable
*/
2019-04-26 18:47:47 +02:00
public yesQuorum(method: MajorityMethod, data: CalculationData, option: ViewAssignmentPollOption): number | null {
const baseAmount =
data.pollMethod === 'votes' ? this.getBaseAmount(data) : this.getOptionBaseAmount(data, option);
2019-03-04 18:28:21 +01:00
return method.calc(baseAmount);
}
2019-04-26 18:47:47 +02:00
/**
* helper function to tuirn a Poll into calculation data for this service
* TODO: temp until better method to normalize Poll ans PollSlideData is implemented
*
* @param poll
* @returns calculationData ready to be used
*/
public calculationDataFromPoll(poll: ViewAssignmentPoll): CalculationData {
return {
pollMethod: poll.pollmethod,
votesno: poll.votesno,
votesabstain: poll.votesabstain,
votescast: poll.votescast,
votesinvalid: poll.votesinvalid,
votesvalid: poll.votesvalid,
pollOptions: poll.options
};
}
2019-03-04 18:28:21 +01:00
}