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

Reviewed-on: #7
This commit is contained in:
Christoph Lienhard 2022-02-02 15:03:48 +01:00
commit dc1602b6e8
24 changed files with 1174 additions and 4368 deletions

View File

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

3
.gitignore vendored
View File

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

15
.graphqlconfig Normal file
View File

@ -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
}
}
}
}

View File

@ -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.

View File

@ -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/'
}

851
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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>

View File

@ -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",

View File

@ -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`),

View File

@ -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 (

View File

@ -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)
}
const apolloResponse = await this.$apollo.query({ query: apolloPersonsForResultsQuery })
const parties = apolloPersonsForResultsUpdate(apolloResponse.data)
this.emphasized = emphasized
this.answers = answers
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)
}
}
}

View File

@ -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()
}
},

View File

@ -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
}) : []
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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)
})
})

View File

@ -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,48 +126,63 @@
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)
getPartyAnswerForThesis (thesis) {
return this.party.theses.find(partyThesis => partyThesis.id === thesis.id)
},
getPartyPosition (thesis) {
const partyAnswerForThesis = this.getPartyAnswerForThesis(thesis)
return partyAnswerForThesis ? partyAnswerForThesis.position : 'skipped'
},
getPartyStatement (thesis) {
const partyAnswerForThesis = this.getPartyAnswerForThesis(thesis)
return partyAnswerForThesis ? partyAnswerForThesis.statement : ''
},
chevronIcon (showStatement) {
return showStatement
? 'chevron-up'
: 'chevron-down'
},
getCategory (category) {
return category[this.$i18n.locale]
},
getThesis (thesis) {
return thesis[this.$i18n.locale]
},
positionToIconName (position) {
switch (position) {
case 'positive':
@ -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
}
}
}

View File

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

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

View File

@ -1,7 +1,4 @@
import { loadContent } from '@/helper/content'
import options from './options'
import theses from './theses'
import parties from './parties'
const i18n = loadContent(
'meta',
@ -9,8 +6,5 @@ const i18n = loadContent(
)
export {
options,
theses,
parties,
i18n
}

File diff suppressed because it is too large Load Diff

View File

@ -1,45 +0,0 @@
const theses = [
{
'id': 0,
'category': {
'de': 'Politische Bildung',
'en': 'Civic Education',
'fr': 'Education civique',
'dk': 'Medborgerskab',
'si': 'Državljanska vzgoja',
'cz': 'Občanské vzdělávání',
'pl': 'Edukacja obywatelska'
},
'thesis': {
'de': 'Europapolitische Bildung sollte Teil der Lehrpläne aller Mitgliedsländer sein.',
'en': 'European civic education should be part of the school curricula of all member countries.',
'fr': 'Léducation civique européenne devrait faire partie des programmes scolaires de tous les Etats-membres.',
'dk': 'Europæisk medborgerskab skal være en del af skolepensum i alle medlemslande.',
'si': 'Evropska državljanska vzgoja bi morala biti del šolskega kurikuluma v vseh državah članicah.',
'cz': 'Evropské občanské vzdělávání by mělo být součástí školních osnov ve všech členských státech.',
'pl': 'Europejska edukacja obywatelska powinna być częścią programów nauczania we wszystkich państwach członkowskich.'
}
},
{
'id': 1,
'category': {
'de': 'Europäische Armee',
'en': 'European Army',
'fr': 'Forces armées',
'dk': 'Fælles forsvar',
'si': 'Evropska vojska',
'cz': 'Evropská armáda',
'pl': 'Armia europejska'
},
'thesis': {
'de': 'Langfristig sollten die EU-Mitgliedstaaten ihre Streitkräfte zu einer europäischen Armee zusammenschließen.',
'en': 'In the long-term EU member states should merge their armed forces to a European army.',
'fr': "A terme, les forces armées des pays membres de l'UE devraient fusionner.",
'dk': 'På lang sigt skal EU-medlemslandenes forsvar samles i en fælles europæisk hær.',
'si': 'Države članice EU bi morale na dolgi rok združiti svoje oborožene sile v skupno evropsko vojsko.',
'cz': 'V dlouhodobém horizontu by měly být armády členských států sloučeny do Evropské armády.',
'pl': 'W perspektywie długoterminowej państwa członkowskie UE powinny połączyć swoje siły zbrojne w armię europejską.'
}
}
]
export default theses

View File

@ -8,14 +8,26 @@ import storage from '@/helper/storage'
import '@/registerComponents'
import '@/registerServiceWorker'
import VueApollo from 'vue-apollo'
import ApolloClient from 'apollo-boost'
Vue.config.productionTip = false
Vue.use(VueSVGIcon)
Vue.use(storage)
Vue.use(VueApollo)
const apolloClient = new ApolloClient({
uri: 'http://localhost:5433/graphql'
})
const apolloProvider = new VueApollo({
defaultClient: apolloClient
})
new Vue({
i18n,
router,
apolloProvider,
data: {
backupStorage: {
answers: undefined,