Compare commits

...

19 commits
v0.1.0 ... main

Author SHA1 Message Date
Willi Junga a90ef4f564 Merge pull request 'Update several: Euromat occurences, URL updates and FAQ' (#10) from willi-euromat into main
Reviewed-on: #10
2022-02-02 23:18:01 +01:00
Willi Junga 41f36cfecb
Update various: removed euromat occurences, changed Meta URL 2022-02-02 23:16:47 +01:00
Willi Junga e6c4c9f9e2
Update various: removed euromat occurences, changed Meta URL 2022-02-02 23:15:56 +01:00
Willi Junga f9b3d1031c
Update various: removed euromat occurences, changed Meta URL 2022-02-02 23:14:59 +01:00
Willi Junga 73e124ee2d Merge pull request 'Update README.md: URL' (#9) from wj-update-readme into main
Reviewed-on: #9
2022-02-02 22:36:16 +01:00
Willi Junga 8dd1b741dc
Update README.md: added Issue note 2022-02-02 22:35:21 +01:00
Willi Junga c006f0a2b7 Merge pull request 'Update README.md: added Issue note' (#8) from wj-update-readme into main
Reviewed-on: #8
2022-02-02 22:33:50 +01:00
Willi Junga 2bee186ea2
Update README.md: added Issue note 2022-02-02 22:32:43 +01:00
Christoph Lienhard dc1602b6e8 Merge pull request 'develop-candymat' (#7) from develop-candymat into main
Reviewed-on: #7
2022-02-02 15:03:48 +01:00
Christoph Lienhard b898bbe184 Rename candymat -> kandimat everywhere 2022-02-02 15:01:15 +01:00
Christoph Lienhard d414b95c1c
Fix several smaller problems
* adapt for structure change in gql backend (e.g. id -> rowId)
* display questions not rowId dependent anymore
* fixed calculation error if both, party and user, selected "skipped"
* fixed problems occuring when a candidate hasn't answered all questions (yet)
2020-12-31 21:13:21 +01:00
Christoph Lienhard f239bec4ff Merge pull request 'feature/get-data-from-backend' (#6) from feature/get-data-from-backend into develop-candymat
Reviewed-on: Netzbegruenung/candymat-user-app#6
2020-08-22 21:16:57 +02:00
Christoph Lienhard 64e6b861b6 Last not Least: Get party details page from backend
Also
* reorder graphQl queries: Always a query constant + update function
* use id instead of token (initials) to identify person in url
* Remove any party logo related sections since currently not needed
* Move "options" from data closer to actual code since it doesn't really belong to data
  (data may be deleted in the future, anyway)
* add nodeId in gql queries wherever possible to avoid problems with the apollo cache
* add note about graphql schema import to README.md
2020-08-22 21:14:12 +02:00
Christoph Lienhard 5512955af4 Add Note in Readme concerning variable naming 2020-08-22 20:49:44 +02:00
Christoph Lienhard 498a02bf1d Use graphql to retrieve parties 2020-06-27 10:53:13 +02:00
Christoph Lienhard f01d492d95 Fix calculation of scores
With this commit two calculation errors are fixed
 * the score in the case partyPosition = 'negative' while userPosition = 'neutral' shouldn't be MIN_POINTS but rather BASE_POINTS.
 * the totalScoredPoints (now "totalMaxPoints") should be independent of any party-positions which it wasn't.

To minimize errors in this area in the future tests are added
which are based on the official Rechenmodel of the bpb.
To that end the score calculation logic was refactored
 and moved from results.vue to scoring.js, too.

(cherry picked from commit 2d246fefbc4730ca5f7a4224325084a98f1c41f0)
2020-06-27 10:32:46 +02:00
Christoph Lienhard 66019ecda9 Use backend on emphasis page 2020-06-14 03:33:56 +02:00
Christoph Lienhard 0347a54ac0 Use backend on theses page 2020-06-14 03:33:39 +02:00
Christoph Lienhard 13be951160 Add vue-apollo to user-app
It does not fetch anything yet.
It points to the graphql server on localhost, which is a hardcoded dev-mode.
Added config for GraphQL JS intellij plugin which inspects a "remote" graphql schema for better type hints in graphql queries in code
2020-06-14 03:00:19 +02:00
27 changed files with 1187 additions and 4377 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,10 @@
# 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.
## Issues
..are tracked @ https://git.netzbegruenung.de/NB-Public/kandimat/issues
## Calculation Model
@ -16,9 +20,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

@ -22,7 +22,7 @@ const configTree = {
meta: {
name: 'euromat-theses',
label: '[Euromat] Theses',
description: 'The theses selection pages for the EUROMAT.',
description: 'The theses selection pages for the KANDIMAT.',
folder: 'src/app/euromat/content/theses'
},
fields: [
@ -55,7 +55,7 @@ const configTree = {
meta: {
name: 'euromat-results',
label: '[Euromat] Results',
description: 'The EUROMAT result page.',
description: 'The KANDIMAT result page.',
folder: 'src/app/euromat/content/results'
},
fields: [

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

@ -11,7 +11,7 @@ public_folder: /img/uploads
collections:
- name: euromat-theses
label: '[Euromat] Theses'
description: The theses selection pages for the EUROMAT.
description: The theses selection pages for the KANDIMAT.
folder: src/app/euromat/content/theses
format: json
create: true
@ -90,7 +90,7 @@ collections:
widget: string
- name: euromat-results
label: '[Euromat] Results'
description: The EUROMAT result page.
description: The KANDIMAT result page.
folder: src/app/euromat/content/results
format: json
create: true

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://kandimat.netzbegruenung.verdigado.net" />
<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: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="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://blog.netzbegruenung.de/files/2013/10/netzbegruenung-logo-website.png" />
<meta property="og:image:secure_url" content="https://blog.netzbegruenung.de/files/2013/10/netzbegruenung-logo-website.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

@ -3,7 +3,7 @@
<header v-if="!isEmbedded" class="app-header">
<router-link :to="{ path: `/${$i18n.locale}/` }">
<img class="header-logo"
alt="EUROMAT Logo"
alt="KANDIMAT Logo"
:src="euromatLogo"
:width="logoSize"
:height="logoSize / 2"
@ -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

@ -13,8 +13,8 @@
"answer": ""
},
{
"title": "Wie haben wir den EUROMAT entwickelt? ",
"answer": ""
"title": "Wie haben wir den KANDIMAT entwickelt? ",
"answer": "In dem wir den Euromat als am einfachsten anzupassende Version einer Software recherchiert und dnn wohl einfach losgelegt haben. Über mehrere Jahre. Den Quellcode findest du auf https://git.netzbegruenung.de/NB-Public/kandimat"
}
]
}

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,