Merge pull request 'develop-candymat' (#7) from develop-candymat into main

Reviewed-on: #7
go-live
Christoph Lienhard 6 months ago
commit dc1602b6e8
  1. 3
      .eslintrc.js
  2. 3
      .gitignore
  3. 15
      .graphqlconfig
  4. 21
      README.md
  5. 2
      jest.config.js
  6. 831
      package-lock.json
  7. 10
      package.json
  8. 12
      public/index.html
  9. 6
      public/manifest.json
  10. 2
      src/app/app.vue
  11. 11
      src/app/euromat/components/emphasis.vue
  12. 208
      src/app/euromat/components/results.vue
  13. 70
      src/app/euromat/components/theses.vue
  14. 109
      src/app/euromat/graphqlQueries.js
  15. 5
      src/app/euromat/possiblePositions.js
  16. 96
      src/app/euromat/scoring.js
  17. 115
      src/app/euromat/scoring.test.js
  18. 136
      src/app/party/components/index.vue
  19. 0
      src/assets/kandimat-logo.png
  20. 1
      src/config/api.js
  21. 6
      src/data/index.js
  22. 3803
      src/data/parties.js
  23. 45
      src/data/theses.js
  24. 12
      src/main.js

@ -1,7 +1,8 @@
module.exports = {
root: true,
env: {
node: true
node: true,
jest: true
},
'extends': [
'plugin:vue/recommended',

3
.gitignore vendored

@ -23,3 +23,6 @@ yarn-error.log*
*.njsproj
*.sln
*.sw*
# GraphQl plugin related
postgraphile-schema.graphql

@ -0,0 +1,15 @@
{
"name": "Postgraphile Schema",
"schemaPath": "postgraphile-schema.graphql",
"extensions": {
"endpoints": {
"Remote SWAPI GraphQL Endpoint": {
"url": "http://localhost:5433/graphql",
"headers": {
"user-agent": "JS GraphQL"
},
"introspect": true
}
}
}
}

@ -1,6 +1,6 @@
# CANDYMAT
# KANDIMAT
A Vue.js powered, progressive web voting application for upcoming internal elections of Bündnis90/Die Grünen. Candymat is hosted as a service of netzbegruenung e.V.
A Vue.js powered, progressive web voting application for upcoming internal elections of Bündnis90/Die Grünen. Kandimat is hosted as a service of netzbegruenung e.V.
## Calculation Model
@ -16,9 +16,24 @@ This is a Vue.js progressive web application, developed with [`@vue/cli`](https:
| `npm run serve` | Serve with hot reload at localhost:8080 |
| `npm run build` | Build for production with minification |
| `npm run test:unit` | Run all unit tests |
| `npm run lint` | Runs `standard` over all `.js` and `.vue` files |
| `npm run lint` | Runs `standard` over all `.js` and `.vue` files and fixes problems |
| `npm run svg` | Creates all SVG files used in the application |
| `npm run admin` | Creates `config.yml` for Netlify CMS admin UI |
### Working with GraphQl backend
As a connector to the backend, `apollo-vue` is used.
Queries are written as `gql` strings.
To have schema hints etc, there is a `.graphqlconfig` file which should help dedicated IDE plugins
to infer the GraphQl schema directly from the (running) backend
(see main project for more information on how a "running backend" is achieved).
For example, the Intellij JS GraphQL plugin will automatically ask to download the schema definition.
### Notes
* To keep the diff to the original euromat source as small as possible certain variables follow a naming convention
which may seem weird at first.
These include
* `party` (better description would be `person`)
## Props
This user app is based on source code of EUROMAT targeted at european elections.

@ -20,7 +20,7 @@ module.exports = {
'jest-serializer-vue'
],
testMatch: [
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
'**/*.test.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
],
testURL: 'http://localhost/'
}

831
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,16 +1,19 @@
{
"name": "candymat",
"name": "kandimat",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "npm run svg && vue-cli-service serve",
"build": "npm run svg && npm run admin && npm run data && vue-cli-service build",
"lint": "vue-cli-service lint",
"lint": "vue-cli-service lint --fix",
"test:unit": "vue-cli-service test:unit",
"svg": "vsvg -s ./src/assets/svg -t ./src/assets/icons",
"admin": "node bin/admin-yml"
},
"dependencies": {
"apollo-boost": "^0.4.9",
"graphql": "^15.1.0",
"graphql-tag": "^2.10.3",
"lint-staged": "^8.1.5",
"register-service-worker": "^1.6.2",
"stylelint": "^10.0.0",
@ -18,6 +21,7 @@
"stylelint-processor-html": "^1.0.0",
"stylelint-webpack-plugin": "^0.10.5",
"vue": "^2.6.6",
"vue-apollo": "^3.0.3",
"vue-feather-icons": "^4.10.0",
"vue-i18n": "^8.10.0",
"vue-markdown": "^2.2.4",
@ -41,7 +45,7 @@
"eslint-plugin-vue": "^5.0.0",
"husky": "^1.3.1",
"js-yaml": "^3.13.1",
"node-sass": "^4.13.1",
"node-sass": "^4.14.1",
"normalize.css": "^8.0.1",
"ora": "^3.4.0",
"sass-loader": "^7.2.0",

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Candymat</title>
<title>Kandimat</title>
<link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon_gruene_16x16.png">
<!--[if IE]><link rel="shortcut icon" href="/img/favicon.ico"><![endif]-->
@ -12,22 +12,22 @@
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#59ae2e">
<!-- Open Graph -->
<meta property="og:url" content="https://www.candymat.info" />
<meta property="og:title" content="Candymat" />
<meta property="og:url" content="https://www.kandimat.info" />
<meta property="og:title" content="Kandimat" />
<meta property="og:locale" content="en_GB" />
<meta property="og:locale:alternate" content="de_DE" />
<meta property="og:locale:alternate" content="fr_FR" />
<meta property="og:locale:alternate" content="cz_CZ" />
<meta property="og:locale:alternate" content="si_SI" />
<meta property="og:locale:alternate" content="dk_DK" />
<meta property="og:description" content="The Candymat is not a regular voting advice application. On the contrary, it is your digital tool navigating you through the policies and visions of the current Candidates. The goal of the Candymat is to support you to make an informed choice for the upcoming European elections!" />
<meta property="og:description" content="The Kandimat is not a regular voting advice application. On the contrary, it is your digital tool navigating you through the policies and visions of the current Candidates. The goal of the Kandimat is to support you to make an informed choice for the upcoming European elections!" />
<meta property="og:image" content="https://www.euromat.info/img/facebook.2.png" />
<meta property="og:image:secure_url" content="https://www.euromat.info/img/facebook.2.png" />
<meta property="fb:app_id" content="766231516835034" />
<!-- Add to home screen for Safari on iOS -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Candymat">
<meta name="apple-mobile-web-app-title" content="Kandimat">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png">
<!-- Add to home screen for Windows -->
<meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png">
@ -51,7 +51,7 @@
</script>
<noscript>
<strong>We're sorry but Candymat doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
<strong>We're sorry but Kandimat doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>

@ -1,7 +1,7 @@
{
"name": "CANDYMAT",
"short_name": "candymat",
"description": "Der Candymat nicht einfach nur ein Wahlomat. Sondern Ihr digitaler Wahl-Freund, der Ihnen einen Eindruck von den politischen Positionen der Kandindaten vermittelt.",
"name": "KANDIMAT",
"short_name": "kandimat",
"description": "Der Kandimat nicht einfach nur ein Wahlomat. Sondern Ihr digitaler Wahl-Freund, der Ihnen einen Eindruck von den politischen Positionen der Kandindaten vermittelt.",
"icons": [
{
"src": "/img/icons/gruenen_logo_200.png",

@ -33,7 +33,7 @@
data () {
return {
euromatLogo: require('@/assets/candymat-logo.png'),
euromatLogo: require('@/assets/kandimat-logo.png'),
logoSize: 220,
languages: SUPPORTED_LOCALES.map(([locale, language]) => ({
icon: require(`@/assets/svg/flag-${locale}.svg`),

@ -37,8 +37,8 @@
</template>
<script>
import { theses } from '@/data'
import { getTranslatedUrl } from '@/i18n/helper'
import { apolloThesesQuery, apolloThesesUpdate } from '@/app/euromat/graphqlQueries'
export default {
name: 'Emphasis',
@ -52,11 +52,18 @@
data () {
return {
theses,
theses: [],
emphasized: []
}
},
apollo: {
theses: {
query: apolloThesesQuery,
update: apolloThesesUpdate
}
},
computed: {
isEmbedded () {
return (

@ -11,17 +11,10 @@
<ul class="party-results">
<li v-for="party of parties" :key="party.token">
<router-link :to="{ path: getPartyPath(party.token) }">
<router-link :to="{ path: getPartyPath(party.id) }">
<div class="result-party-info">
<div class="result-party-logo">
<img
v-if="hasPartyLogo(party.token)"
:src="getPartyLogo(party.token)"
width="50"
height="50"
:alt="party.token"
>
<span v-else>{{ party.token }}</span>
<span>{{ party.token }}</span>
</div>
<h2>{{ getScorePercentage(party.score) }}%</h2>
@ -32,31 +25,13 @@
<v-progress
class="result-percentage"
:value="party.score"
:max="totalScoredPoints"
:max="totalMaxPoints"
/>
</router-link>
<div v-if="party.nationalParty" class="party-results-national">
<feather-corner-down-right />
<div class="party-results-national">
<span>
{{ $t('results.nationalParty') }}
<a
class="party-results-national-logo"
:href="party.nationalParty.program"
target="_blank"
rel="noopener"
>
<div v-if="hasPartyLogo(party.nationalParty.token)">
<img
:src="getPartyLogo(party.nationalParty.token)"
:alt="party.nationalParty.name"
:title="party.nationalParty.name"
width="40"
height="40"
>
</div>
<span v-else>{{ party.nationalParty.token }}</span>
</a>
{{ party.name }}
</span>
</div>
</li>
@ -79,36 +54,16 @@
<feather-rotate-cw />
</router-link>
</div>
<div class="results-affiliation">
<a
href="https://www.talkingeurope.com/"
target="_blank"
rel="noopener"
>
<img
:src="talkingEuropeBanner"
title="Talking Europe"
alt="Talking Europe Banner"
>
</a>
</div>
</section>
</template>
<script>
import { IPDATA_URL } from '@/config/api'
import { getTranslatedUrl, getUserLanguage } from '@/i18n/helper'
import { getTranslatedUrl } from '@/i18n/helper'
import { getPartiesWithScores, getTotalMaxPoints } from '@/app/euromat/scoring'
import {
MAX_POINTS,
BASE_POINTS,
MIN_POINTS,
EMPHASIS_POINTS,
getScoringGrid
} from '@/app/euromat/scoring'
import { parties } from '@/data'
const addUp = (a, b) => a + b
apolloPersonsForResultsQuery,
apolloPersonsForResultsUpdate
} from '@/app/euromat/graphqlQueries'
export default {
name: 'Results',
@ -117,20 +72,15 @@
'feather-zoom-in': () =>
import('vue-feather-icons/icons/ZoomInIcon' /* webpackChunkName: "icons" */),
'feather-rotate-cw': () =>
import('vue-feather-icons/icons/RotateCwIcon' /* webpackChunkName: "icons" */),
'feather-corner-down-right': () =>
import('vue-feather-icons/icons/CornerDownRightIcon' /* webpackChunkName: "icons" */)
import('vue-feather-icons/icons/RotateCwIcon' /* webpackChunkName: "icons" */)
},
data () {
return {
userCountry: getUserLanguage().country,
scoringGrid: [],
answers: [],
emphasized: [],
scores: [],
parties,
totalScoredPoints: 0
parties: [],
totalMaxPoints: 0
}
},
@ -143,14 +93,6 @@
this.$route.query.embedded &&
this.$route.query.embedded === 'iframe'
)
},
talkingEuropeBanner () {
try {
return require(`@/assets/talkingeurope/talkingeurope-${this.$i18n.locale}.png`)
} catch (e) {
console.warn('TalkingEurope image not found, defaulting to "en". ', e)
return require(`@/assets/talkingeurope/talkingeurope-en.png`)
}
}
},
@ -167,126 +109,30 @@
}
if (!emphasized) {
this.$router.push({ path: getTranslatedUrl('theses') })
await this.$router.push({ path: getTranslatedUrl('theses') })
}
try {
const ipResponse = await fetch(IPDATA_URL)
const ipData = await ipResponse.json()
if (ipData.country_code) {
this.userCountry = ipData.country_code.toLowerCase()
}
} catch (error) {
console.warn('Unable to fetch geo location:', error)
}
this.emphasized = emphasized
this.answers = answers
const apolloResponse = await this.$apollo.query({ query: apolloPersonsForResultsQuery })
const parties = apolloPersonsForResultsUpdate(apolloResponse.data)
this.scoringGrid = getScoringGrid(this.answers, this.emphasized)
this.scores = this.getScorePoints(this.scoringGrid)
this.parties = this.parties
.map(this.getScorePerParty)
const partiesWithScores = getPartiesWithScores(answers, emphasized, parties)
this.parties = partiesWithScores.map(party => ({
id: party.id,
token: party.token,
score: party.score,
name: party.name
}))
.sort((a, b) => a.score - b.score)
.reverse()
this.totalScoredPoints = this.scores
.map(s => s.highestScore)
.reduce(addUp, 0)
this.totalMaxPoints = getTotalMaxPoints(answers, emphasized)
},
methods: {
getPartyPath (token) {
return `${getTranslatedUrl('party')}/${token.toLowerCase()}`
},
getPartyLogo (token) {
try {
return require(`@/assets/svg/${token.toLowerCase().replace(/\s/g, '-')}-logo.svg`)
} catch (e) {
try {
return require(`@/assets/${token.toLowerCase().replace(/\s/g, '-')}-logo.png`)
} catch (error) {
console.warn(`No logo found for party "${token}", falling back to initials.`, error.message)
return false
}
}
},
hasPartyLogo (token) {
try {
require(`@/assets/svg/${token.toLowerCase().replace(/\s/g, '-')}-logo.svg`)
return true
} catch (e) {
try {
require(`@/assets/${token.toLowerCase().replace(/\s/g, '-')}-logo.png`)
return true
} catch (error) {
return false
}
}
getPartyPath (partyId) {
return `${getTranslatedUrl('party')}/${partyId}`
},
getScorePercentage (score) {
return (score / this.totalScoredPoints * 100).toFixed(2)
},
evalPoints (party, user, emphasis) {
let score = 0
if (user.position === party.position) {
score = MAX_POINTS
} else if (
(user.position === 'positive' && party.position === 'neutral') ||
(user.position === 'neutral' && party.position === 'positive') ||
(user.position === 'negative' && party.position === 'neutral')
) {
score = BASE_POINTS
} else if (
(user.position === 'positive' && party.position === 'negative') ||
(user.position === 'neutral' && party.position === 'negative') ||
(user.position === 'negative' && party.position === 'positive')
) {
score = MIN_POINTS
}
return {
party: party.party,
score: emphasis ? score * EMPHASIS_POINTS : score
}
},
getHighestScore (scores) {
const highestScore = Math.max(...scores.map(s => s.score))
if (!highestScore) {
return MIN_POINTS
}
return highestScore === 1
? MAX_POINTS
: highestScore
},
getScorePoints (grid) {
// 1. Iterate over scoringGrid
// 2. Get user and party positions of each thesis
// 3. Evaluate points based on calculation model for each party
// 4. Count the highest score per thesis
// 5. Return a new object for each thesis row with results
return grid.map(row => {
const partiesFromRow = row.positions.filter(p => p.type === 'party')
const user = row.positions[row.positions.length - 1]
const scores = partiesFromRow.map(party => this.evalPoints(party, user, row.emphasis))
const highestScore = this.getHighestScore(scores)
return {
thesis: row.thesis,
highestScore,
scores
}
})
},
getScorePerParty (party) {
return {
token: party.token,
score: this.scores
.map(t => t.scores.find(s => s.party === party.id).score)
.reduce(addUp, 0),
nationalParty: party['national_parties'][this.userCountry]
}
return (score / this.totalMaxPoints * 100).toFixed(2)
}
}
}

@ -1,12 +1,12 @@
<template>
<section class="theses">
<section v-if="thesesCount > 0 && theses.length > 0" class="theses">
<div class="header-progress">
<div>
<span class="progress-current">{{ currentThesisStep }}</span>
<span>/{{ thesesCount }}</span>
</div>
<button
:disabled="currentThesis === 0"
:disabled="currentThesisStep === 1"
class="btn-dark btn-small"
type="button"
@click="goBack"
@ -23,9 +23,9 @@
<div class="theses-controls">
<ul class="theses-btns">
<li v-for="option in options" :key="option.label">
<button type="button" @click="submitAnswer(option, $event)">
{{ option.label }} <component :is="'feather-' + option.icon" />
<li v-for="possiblePosition in possiblePositions" :key="possiblePosition.label">
<button type="button" @click="submitAnswer(possiblePosition, $event)">
{{ possiblePosition.label }} <component :is="'feather-' + possiblePosition.icon" />
</button>
</li>
</ul>
@ -35,17 +35,27 @@
type="button"
@click="submitAnswer(optionSkip)"
>
{{ optionSkip.label }} <feather-corner-up-right />
{{ optionSkip.label }}
<feather-corner-up-right />
</button>
</div>
</div>
</div>
</section>
<section v-else>
<span>Loading...</span>
</section>
</template>
<script>
import { options, theses } from '@/data'
import possiblePositions from '@/app/euromat/possiblePositions'
import { getTranslatedUrl } from '@/i18n/helper'
import {
apolloThesesCountQuery,
apolloThesesCountUpdate,
apolloThesesQuery,
apolloThesesUpdate
} from '@/app/euromat/graphqlQueries'
export default {
name: 'EuroMat',
@ -63,12 +73,24 @@
data () {
return {
currentThesis: 0,
thesesCount: theses.length,
currentThesisStep: 1,
theses: [],
thesesCount: 0,
answers: []
}
},
apollo: {
theses: {
query: apolloThesesQuery,
update: apolloThesesUpdate
},
thesesCount: {
query: apolloThesesCountQuery,
update: apolloThesesCountUpdate
}
},
computed: {
isEmbedded () {
return (
@ -76,23 +98,20 @@
this.$route.query.embedded === 'iframe'
)
},
currentThesisStep () {
return this.currentThesis + 1
},
thesisTitle () {
if (this.currentThesis === this.thesesCount) {
if (this.currentThesisStep > this.thesesCount) {
return
}
return this.getThesis(this.currentThesis).thesis[this.$i18n.locale]
return this.theses[this.currentThesisStep - 1].thesis[this.$i18n.locale]
},
thesisCategory () {
if (this.currentThesis === this.thesesCount) {
if (this.currentThesisStep > this.thesesCount) {
return
}
return this.getThesis(this.currentThesis).category[this.$i18n.locale]
return this.theses[this.currentThesisStep - 1].category[this.$i18n.locale]
},
options () {
return options.map(option =>
possiblePositions () {
return possiblePositions.map(option =>
Object.assign({}, option, {
label: this.$t(`theses.${option.position}`),
icon: this.getIconName(option.position)
@ -100,7 +119,7 @@
.filter(option => option.position !== 'skipped')
},
optionSkip () {
const skipped = options.find(option => option.position === 'skipped')
const skipped = possiblePositions.find(option => option.position === 'skipped')
return Object.assign({}, skipped, {
label: this.$t('theses.skipped')
})
@ -117,14 +136,11 @@
default:
}
},
getThesis (id) {
return theses.find(t => t.id === id)
},
goBack () {
const thesis = this.getThesis(this.currentThesis)
const thesis = this.theses[this.currentThesisStep - 1]
const index = this.answers.findIndex(a => a.thesis === thesis.id)
this.answers.splice(index, 1)
this.currentThesis -= 1
this.currentThesisStep -= 1
},
submitAnswer (option, event) {
if (!option) {
@ -132,13 +148,13 @@
return console.warn('Invalid answer')
}
const thesis = this.getThesis(this.currentThesis)
const thesis = this.theses[this.currentThesisStep - 1]
this.answers.push({ thesis: thesis.id, position: option.position })
this.currentThesis += 1
this.currentThesisStep += 1
event && event.target.blur()
window.scrollTo(0, 0)
if (this.currentThesis === this.thesesCount) {
if (this.currentThesisStep > this.thesesCount) {
this.forwardToResults()
}
},

@ -0,0 +1,109 @@
import gql from 'graphql-tag'
import possiblePositions from '@/app/euromat/possiblePositions'
export function getPositionById (id) {
return possiblePositions.find(option => option.id === id).position
}
export const apolloThesesQuery = gql`{
allQuestions(orderBy: ROW_ID_ASC) {
nodes {
category: categoryByCategoryRowId {
id
title
}
title
rowId
id
}
}
}`
export const apolloThesesUpdate = data => data.allQuestions.nodes.map(node => ({
id: node.rowId,
thesis: {
de: node.title
},
category: {
de: node.category ? node.category.title : ''
}
}))
export const apolloThesesCountQuery = gql`{
allQuestions {
totalCount
}
}`
export const apolloThesesCountUpdate = data => data.allQuestions.totalCount
export const apolloPersonsForResultsQuery = gql`{
allPeople(condition: {role: KANDIMAT_CANDIDATE}) {
nodes {
id
firstName
lastName
rowId
answers: answersByPersonRowId {
nodes {
id
position
questionRowId
text
}
}
}
}
}`
export const apolloPersonsForResultsUpdate = data => data.allPeople.nodes.map(person => ({
id: person.rowId,
name: `${person.firstName} ${person.lastName}`,
token: person.firstName.charAt(0) + person.lastName.charAt(0),
positions: person.answers.nodes.map(answer => ({
thesis: answer.questionRowId,
position: getPositionById(answer.position),
statement: {
de: answer.text
}
}))
}))
export const apolloPersonPositionsQuery = gql`
query Person($partyId: Int!) {
personByRowId(rowId: $partyId) {
id
firstName
lastName
answers: answersByPersonRowId {
nodes {
id
position
personRowId
text
question: questionByQuestionRowId {
id
rowId
}
}
}
}
}`
export const apolloPersonPositionsUpdate = data => {
const person = data.personByRowId
return {
id: person.rowId,
name: `${person.firstName} ${person.lastName}`,
token: person.firstName.charAt(0) + person.lastName.charAt(0),
theses: person.answers ? person.answers.nodes.map(answer => {
const question = answer.question
return question ? {
id: question.rowId,
position: getPositionById(answer.position),
statement: answer.text,
showStatement: false
} : null
}) : []
}
}

@ -1,4 +1,4 @@
const options = [
const possiblePositions = [
{
'position': 'positive',
'id': 0
@ -16,5 +16,4 @@ const options = [
'id': 3
}
]
export default options
export default possiblePositions

@ -1,21 +1,60 @@
import { parties } from '@/data'
export const MAX_POINTS = 2
export const BASE_POINTS = 1
export const MIN_POINTS = 0
export const EMPHASIS_POINTS = 2
export function getPartyPositions (thesis) {
return parties.map(party => {
const position = party.positions.find(p => p.thesis === thesis)
export function getPartiesWithScores (answers, emphasized, partiesPositions) {
const scorePointsGrid = getScorePointsGrid(answers, emphasized, partiesPositions)
return partiesPositions.map(party => ({
...party,
score: getTotalScorePerParty(party, scorePointsGrid)
}))
}
export function getTotalMaxPoints (userAnswers, userEmphasized) {
return userAnswers.map(answer => {
const emphasis = userEmphasized.filter(e => e.thesis === answer.thesis).length >= 1
return getMaxScorePerThesis(answer.position, emphasis)
}).reduce((total, maxScorePerRow) => total + maxScorePerRow)
}
function getScorePointsGrid (userAnswers, userEmphasized, partiesPositions) {
// 1. Iterate over scoringGrid
// 2. Get user and party positions of each thesis
// 3. Evaluate points based on calculation model for each party
// 4. Get the maximum score per thesis
// 5. Return a new object for each thesis row with results
const scoringGrid = getScoringGrid(userAnswers, userEmphasized, partiesPositions)
return scoringGrid.map(row => {
const partiesFromRow = row.positions.filter(p => p.type === 'party')
const userPosition = getUserPosition(row)
const scores = partiesFromRow.map(party => ({
party: party.party,
score: evalPointsPerThesisPerParty(party.position, userPosition, row.emphasis)
}))
return {
type: 'party',
party: party.id,
position: (position && position.position) || {}
thesis: row.thesis,
scores
}
})
}
function getTotalScorePerParty (party, scorePointsGrid) {
return scorePointsGrid
.map(thesis => thesis.scores.find(scores => scores.party === party.id).score)
.reduce((total, score) => total + score, 0)
}
function getMaxScorePerThesis (userPosition, emphasis) {
return userPosition === 'skipped' ? MIN_POINTS : emphasis ? MAX_POINTS * EMPHASIS_POINTS : MAX_POINTS
}
function getUserPosition (row) {
return row.positions.find(p => p.type === 'user').position
}
// Grid example:
// [
// {
@ -33,15 +72,50 @@ export function getPartyPositions (thesis) {
// },
// ...
// ]
export function getScoringGrid (userAnswers, emphasizedTheses) {
return userAnswers.map(answer => (
function getScoringGrid (userAnswers, emphasizedTheses, parties) {
const grid = userAnswers.map(answer => (
{
thesis: answer.thesis,
emphasis: emphasizedTheses.filter(e => e.thesis === answer.thesis).length >= 1,
positions: [
...getPartyPositions(answer.thesis),
...getPartyPositions(answer.thesis, parties),
...[{ type: 'user', position: answer.position }]
]
}
))
return grid
}
function getPartyPositions (thesis, parties) {
return parties.map(party => {
const position = party.positions.find(p => p.thesis === thesis)
return {
type: 'party',
party: party.id,
position: (position && position.position) || 'skipped'
}
})
}
export function evalPointsPerThesisPerParty (partyPosition, userPosition, emphasis) {
let score = 0
if (partyPosition === 'skipped' || userPosition === 'skipped') {
score = MIN_POINTS
} else if (userPosition === partyPosition) {
score = MAX_POINTS
} else if (
(userPosition === 'positive' && partyPosition === 'neutral') ||
(userPosition === 'neutral' && partyPosition === 'positive') ||
(userPosition === 'neutral' && partyPosition === 'negative') ||
(userPosition === 'negative' && partyPosition === 'neutral')
) {
score = BASE_POINTS
} else if (
(userPosition === 'positive' && partyPosition === 'negative') ||
(userPosition === 'negative' && partyPosition === 'positive')
) {
score = MIN_POINTS
}
return emphasis ? score * EMPHASIS_POINTS : score
}

@ -0,0 +1,115 @@
import { evalPointsPerThesisPerParty, getPartiesWithScores, getTotalMaxPoints } from '@/app/euromat/scoring'
const positionDef = ['positive', 'neutral', 'negative', 'skipped']
// See offficial Rechenmodell of bpb from 2019
const rechenmodellGrid = [
[0, 0, 0, 0, 0, false],
[0, 0, 2, 0, 1, false],
[0, 2, 2, 0, 0, false],
[0, 0, 0, 0, 2, false],
[1, 1, 0, 2, 2, true],
[0, 0, 2, 0, 0, false],
[0, 2, 2, 1, 0, false],
[0, 0, 0, 2, 2, false],
[2, 0, 2, 2, 1, false],
[1, 2, 2, 2, 1, false],
[2, 2, 0, 0, 2, false],
[0, 2, 2, 2, 2, false],
[0, 2, 0, 0, 3, false],
[0, 2, 1, 2, 2, false],
[2, 0, 0, 1, 2, false],
[2, 0, 0, 0, 2, true],
[2, 0, 0, 2, 3, false],
[0, 0, 0, 0, 2, false],
[2, 0, 0, 0, 1, false],
[2, 0, 0, 0, 1, false],
[0, 0, 2, 2, 2, true],
[2, 2, 1, 2, 2, false],
[2, 2, 0, 2, 2, false],
[2, 2, 2, 0, 2, false],
[0, 2, 0, 2, 1, false],
[1, 2, 2, 0, 0, false],
[2, 0, 2, 0, 0, false],
[2, 0, 0, 2, 2, false],
[0, 2, 0, 2, 1, false],
[1, 2, 0, 2, 2, false],
[0, 1, 2, 0, 0, false],
[1, 2, 2, 0, 1, false],
[2, 0, 2, 2, 1, false],
[0, 2, 2, 2, 2, false],
[0, 2, 0, 0, 0, false],
[0, 2, 0, 0, 1, false],
[2, 2, 2, 2, 0, false],
[2, 0, 0, 2, 0, false]
]
const rechenmodellExpectedTotalScorePerParty = [44, 37, 28, 50]
const rechenmodellExpectedMaxScore = 78
const testParties = [1, 2, 3, 4].map(partyId => ({
id: partyId,
token: 'idontcare',
positions: rechenmodellGrid.map((thesis, index) => ({
thesis: index,
position: positionDef[thesis[partyId - 1]]
}))
}))
const testAnswers = rechenmodellGrid.map((thesis, index) => ({
position: positionDef[thesis[4]],
thesis: index
}))
const testEmphasis = rechenmodellGrid
.map((thesis, index) => ({
...thesis,
thesis: index
}))
.filter((thesis, index) => thesis[5])
.map(thesis => ({ thesis: thesis.thesis }))
describe('The getPartiesWithScores function', () => {
it('returns the correct total scores according to the Rechenmodell example of bpb', () => {
const resultPartiesWithScores = getPartiesWithScores(testAnswers, testEmphasis, testParties)
expect(resultPartiesWithScores.map(party => party.score)).toEqual(rechenmodellExpectedTotalScorePerParty)
})
})
describe('The getTotalMaxPoints function', () => {
it('returns the correct maximum score points according to the Rechenmodell example of bpb', () => {
const resultTotalMaxPoints = getTotalMaxPoints(testAnswers, testEmphasis)
expect(resultTotalMaxPoints).toEqual(rechenmodellExpectedMaxScore)
})
})
describe('The evalPointsPerThesisPerParty fucntion', () => {
test.each`
partyPosition | userPosition | expectedScore | expectedScoreWithEmphasis
${'negative'} | ${'negative'} | ${2} | ${4}
${'negative'} | ${'neutral'} | ${1} | ${2}
${'negative'} | ${'positive'} | ${0} | ${0}
${'negative'} | ${'skipped'} | ${0} | ${0}
${'neutral'} | ${'negative'} | ${1} | ${2}
${'neutral'} | ${'neutral'} | ${2} | ${4}
${'neutral'} | ${'positive'} | ${1} | ${2}
${'neutral'} | ${'skipped'} | ${0} | ${0}
${'positive'} | ${'negative'} | ${0} | ${0}
${'positive'} | ${'neutral'} | ${1} | ${2}
${'positive'} | ${'positive'} | ${2} | ${4}
${'positive'} | ${'skipped'} | ${0} | ${0}
${'skipped'} | ${'negative'} | ${0} | ${0}
${'skipped'} | ${'neutral'} | ${0} | ${0}
${'skipped'} | ${'positive'} | ${0} | ${0}
${'skipped'} | ${'skipped'} | ${0} | ${0}
`('returns the correct score (according to the Rechenmodell of the bpb' +
' if the party position is $partyPosition and the user\'s position is $userPosition',
({ partyPosition, userPosition, expectedScore, expectedScoreWithEmphasis }) => {
const resultScore = evalPointsPerThesisPerParty(partyPosition, userPosition, false)
const resultScoreWithEmphasis = evalPointsPerThesisPerParty(partyPosition, userPosition, true)
expect(resultScore).toEqual(expectedScore)
expect(resultScoreWithEmphasis).toEqual(expectedScoreWithEmphasis)
})
})

@ -1,28 +1,21 @@
<template>
<section>
<header :class="['party-header', { 'no-party-link': !partyProgramLink }]">
<div :class="['party-header-logo', { 'no-logo': !hasPartyLogo }]">
<img
v-if="hasPartyLogo"
:src="partyLogo"
:width="logoSize"
:height="logoSize"
:alt="partyName"
>
<span v-else>
{{ partyToken }}
<div :class="['party-header-logo', 'no-logo']">
<span>
{{ party.token }}
</span>
</div>
<div class="party-header-info">
<router-link v-if="!!answers"
class="btn btn-dark btn-small"
:to="{ path: resultsPath }"
class="btn btn-dark btn-small"
:to="{ path: resultsPath }"
>
{{ $t('party.backButtonLabel') }}
<feather-corner-up-left />
</router-link>
<h1>{{ partyName }}</h1>
<h1>{{ party.name }}</h1>
<a
v-if="!!partyProgramLink"
class="btn"
@ -38,9 +31,9 @@
<div class="theses-legend">
<p>{{ $t('party.legendLabel') }}:</p>
<ul>
<li v-for="option in options" :key="option.position">
<component :is="'feather-' + positionToIconName(option.position)" />
<span>{{ $t(`theses.${option.position}`) }}</span>
<li v-for="possiblePosition in possiblePositions" :key="possiblePosition.position">
<component :is="'feather-' + positionToIconName(possiblePosition.position)" />
<span>{{ $t(`theses.${possiblePosition.position}`) }}</span>
</li>
</ul>
</div>
@ -56,34 +49,34 @@
<li v-for="thesis in theses" :key="thesis.id">
<div class="thesis-facts">
<div class="list-thesis" @click="toggleStatement(thesis.id)">
<div class="list-thesis" @click="toggleStatement(thesis)">
<div class="thesis-subline">
<component :is="'feather-' + chevronIcon(thesis.id)" />
<span>{{ getCategory(thesis.category) }}</span>
<component :is="'feather-' + chevronIcon(thesis.showStatement)" />
<span>{{ thesis.category[$i18n.locale] }}</span>
</div>
<h3>{{ getThesis(thesis.thesis) }}</h3>
<h3>{{ thesis.thesis[$i18n.locale] }}</h3>
</div>
<div class="statements-party">
<component :is="'feather-' + getPartyPosition(thesis.id)" />
<component :is="'feather-' + positionToIconName(getPartyPosition(thesis))" />
</div>
<div v-if="!!answers" class="statements-user">
<component :is="'feather-' + getUserPosition(thesis.id)" />
</div>
</div>
<div v-show="showStatement(thesis.id)" class="thesis-statement">
<div v-show="thesis.showStatement" class="thesis-statement">
<p><strong>{{ $t('party.partyAnswer') }}:</strong></p>
<blockquote>
{{ getPartyStatement(thesis.id) }}
{{ getPartyStatement(thesis) }}
</blockquote>
</div>
</li>
</ul>
<router-link v-if="!!answers"
class="btn btn-dark btn-small"
:to="{ path: resultsPath }"
class="btn btn-dark btn-small"
:to="{ path: resultsPath }"
>
{{ $t('party.backButtonLabel') }}
<feather-corner-up-left />
@ -92,8 +85,14 @@
</template>
<script>
import { parties, theses, options } from '@/data'
import possiblePositions from '@/app/euromat/possiblePositions'
import { getTranslatedUrl } from '@/i18n/helper'
import {
apolloPersonPositionsQuery,
apolloPersonPositionsUpdate,
apolloThesesQuery, apolloThesesUpdate
} from '@/app/euromat/graphqlQueries'
export default {
name: 'Party',
@ -127,47 +126,62 @@
return {
logoSize: 60,
partyLogo: this.hasPartyLogo && require(`@/assets/svg/${this.$route.params.token}-logo.svg`),
partyToken: this.$route.params.token.toUpperCase(),
party: parties.find(p => p.token === this.$route.params.token.toUpperCase()),
partyId: this.$route.params.token,
party: {
id: this.$route.params.token,
token: this.$route.params.token,
name: 'Loading ...',
theses: []
},
theses: [],
answers,
toggles: theses.map(t => ({ id: t.id, show: false })),
theses,
options
possiblePositions
}
},
apollo: {
party: {
query: apolloPersonPositionsQuery,
variables () {
return {
partyId: parseInt(this.$route.params.token)
}
},
update: apolloPersonPositionsUpdate
},
theses: {
query: apolloThesesQuery,
update: data => {
const allTheses = apolloThesesUpdate(data)
return allTheses.map(thesis => ({ ...thesis, showStatement: false }))
}
}
},
computed: {
resultsPath () {
return getTranslatedUrl('results', getTranslatedUrl('theses', null, true))
},
hasPartyLogo () {
try {
require(`@/assets/svg/${this.$route.params.token}-logo.svg`)
return true
} catch (error) {
return false
}
},
partyName () {
return this.party.name[this.$i18n.locale]
},
partyProgramLink () {
return this.party.program[this.$i18n.locale]
return false
}
},
methods: {
chevronIcon (id) {
return this.showStatement(id)
? 'chevron-up'
: 'chevron-down'
getPartyAnswerForThesis (thesis) {
return this.party.theses.find(partyThesis => partyThesis.id === thesis.id)
},
getCategory (category) {
return category[this.$i18n.locale]
getPartyPosition (thesis) {
const partyAnswerForThesis = this.getPartyAnswerForThesis(thesis)
return partyAnswerForThesis ? partyAnswerForThesis.position : 'skipped'
},
getThesis (thesis) {
return thesis[this.$i18n.locale]
getPartyStatement (thesis) {
const partyAnswerForThesis = this.getPartyAnswerForThesis(thesis)
return partyAnswerForThesis ? partyAnswerForThesis.statement : ''
},
chevronIcon (showStatement) {
return showStatement
? 'chevron-up'
: 'chevron-down'
},
positionToIconName (position) {
switch (position) {
@ -182,27 +196,13 @@
return 'circle'
}
},
getPartyPosition (id) {
return this.positionToIconName(
this.party.positions.find(p => p.thesis === id).position
)
},
getPartyStatement (id) {
return this.party.positions
.find(p => p.thesis === id)
.statement[this.$i18n.locale]
},
getUserPosition (id) {
return this.positionToIconName(
this.answers.find(a => a.thesis === id).position
)
},
showStatement (id) {
return this.toggles.find(t => t.id === id).show
},
toggleStatement (id) {
const statement = this.toggles.find(t => t.id === id)
statement.show = !statement.show
toggleStatement (thesis) {
thesis.showStatement = !thesis.showStatement
}
}
}

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

@ -1 +0,0 @@
export const IPDATA_URL = 'https://api.ipdata.co/?api-key=test'

@ -1,7 +1,4 @@
import { loadContent } from '@/helper/content'
import options from './options'
import theses from '