Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Challenge Page): A page for community challenges #935

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/components/ChallengeExample.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<template>
<v-row v-if="priceExample">
<v-col>
<h2 class="text-h6 mb-1">
A good example of proof to photograph
</h2>
<v-img :src="priceExample.proofUrl" style="max-height: 200px" />
</v-col>
<v-col>
<h2 class="text-h6 mb-1">
A good example of price to add
</h2>
<PriceCard :price="priceExample" :product="priceExample.product" elevation="1" />
</v-col>
</v-row>
</template>

<script>
import { defineAsyncComponent } from 'vue'
import api from '../services/api.js'

export default {
components: {
PriceCard: defineAsyncComponent(() => import('./PriceCard.vue')),
},
props: {
exampleId: {
type: Number,
default: () => {}
}
},
data() {
return {
priceExample: null
}
},
mounted() {
api.getPriceById(this.exampleId)
.then((data) => {
data.proofUrl = `${import.meta.env.VITE_OPEN_PRICES_APP_URL}/img/${data.proof.file_path}`
this.priceExample = data
})
}
}
</script>

58 changes: 58 additions & 0 deletions src/components/ChallengeTimeline.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<template>
<v-timeline direction="horizontal" truncate-line="both">
<v-timeline-item dot-color="success">
<div class="text-h6">
Start
</div>
<template #opposite>
{{ challenge.startDate }}
</template>
</v-timeline-item>
<v-timeline-item dot-color="error">
<template #opposite>
<div class="text-h6">
End
</div>
</template>
{{ challenge.endDate }}
</v-timeline-item>
</v-timeline>
<v-progress-linear
color="info"
height="25"
:model-value="progress"
striped
style="width: 50%; margin-left: 25%; top: -55px; margin-top: -25px"
>
<strong>{{ daysLeftText }}</strong>
</v-progress-linear>
</template>

<script>
export default {
props: {
challenge: {
type: Object,
default: () => {}
}
},
computed: {
nbDays() {
return Math.round((new Date(this.challenge.endDate) - new Date(this.challenge.startDate)) / (1000 * 60 * 60 * 24))
},
todayIndex() {
return Math.round((new Date() - new Date(this.challenge.startDate)) / (1000 * 60 * 60 * 24))
},
daysLeftText() {
const daysLeft = this.nbDays - this.todayIndex
if (daysLeft <= 0) return "Challenge over"
if (daysLeft >= this.nbDays) return "Not started"
return `${daysLeft} days left`
},
progress() {
return Math.min(100, this.todayIndex * 100 / this.nbDays)
}
}
}
</script>

38 changes: 38 additions & 0 deletions src/components/ChallengeTopContributors.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<template>
<h2 class="text-h6 mb-1">
Top 5 contributor
</h2>
<v-list density="compact">
<v-list-item
v-for="(contributor, i) in topContributors.slice(0, 5)"
:key="i"
:value="contributor"
color="primary"
>
<template #prepend>
{{
i === 0 ? '🥇' :
i === 1 ? '🥈' :
i === 2 ? '🥉' :
'🏅'
}}
</template>

<v-list-item-title>
{{ i+1 }}. {{ contributor.user_id }}, {{ contributor.price_count }} prices
</v-list-item-title>
</v-list-item>
</v-list>
</template>

<script>
export default {
props: {
topContributors: {
type: Array,
default: () => []
}
},
}
</script>

15 changes: 12 additions & 3 deletions src/components/PriceChart.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div id="vega-lite-chart" />
<div id="vega-lite-chart" style="width: 100%;" />
</template>

<script>
Expand All @@ -13,6 +13,14 @@ export default {
priceList: {
type: Array,
default: () => []
},
aggregate: {
type: String,
default: () => "mean"
},
dateField: {
type: String,
default: () => "date"
}
},
data() {
Expand All @@ -33,6 +41,7 @@ export default {
var vlSpec = {
$schema: 'https://vega.github.io/schema/vega-lite/v5.json',
description: 'A simple bar chart with embedded data.',
width: "container",
autosize: { type: 'fit', resize: true},
data: {
values: this.priceList
Expand All @@ -43,9 +52,9 @@ export default {
tooltip: true
},
encoding: {
x: {timeUnit: 'yearmonthdate', field: 'date', type: 'temporal', axis: { title: this.$t('Common.Date') }},
x: {timeUnit: 'yearmonthdate', field: this.dateField, type: 'temporal', axis: { title: this.$t('Common.Date') }},
// y: {field: 'price', type: 'quantitative'}
y: {aggregate: 'mean', field: 'price', type: 'quantitative', axis: { title: this.$t('Common.Price') }},
y: {aggregate: this.aggregate, field: 'price', type: 'quantitative', axis: { title: this.$t('Common.Price') }},
}
}
embed('#vega-lite-chart', vlSpec, {actions: false, theme: this.theme.global.name})
Expand Down
1 change: 1 addition & 0 deletions src/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const routes = [
{ path: '/users/:username', name: 'user-detail', component: () => import('./views/UserDetail.vue'), meta: { title: 'User detail' }},
{ path: '/stats', name: 'stats', component: () => import('./views/Stats.vue'), meta: { title: 'Stats', icon: 'mdi-chart-box-outline', drawerMenu: true }},
{ path: '/about', name: 'about', component: () => import('./views/About.vue'), meta: { title: 'About', icon: 'mdi-information-outline', drawerMenu: true }},
{ path: '/challenge', name: 'challenge', component: () => import('./views/CurrentChallenge.vue'), meta: { title: 'Current Challenge', icon: 'mdi-medal-outline', drawerMenu: true }},
// Why this redirect?
// The app used to be available at https://prices.openfoodfacts.org/app
// It is now available at https://prices.openfoodfacts.org
Expand Down
175 changes: 175 additions & 0 deletions src/views/CurrentChallenge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<template>
<h1 class="text-h5 mb-1">
The current Challenge is ... {{ challenge.icon }} {{ challenge.title }} {{ challenge.icon }}
</h1>

<h2 class="text-h6 mb-1">
{{ challenge.subtitle }}
</h2>

<v-row>
<v-col cols="12" lg="6">
<blockquote class="blockquote">
<p class="mb-2">
{{ challenge.icon }} Join other food price enthousiast (such as {{ challenge.topContributors[0]?.user_id || "you?" }}) in gathering information about {{ challenge.title }} {{ challenge.subtitle }}.
</p>
<p class="mb-2">
{{ challenge.icon }} Your goal is to go nearby stores, find corresponding products and
<v-btn to="/prices/add" variant="text" density="compact">
adding prices
</v-btn>
</p>
<p class="mb-2">
{{ challenge.icon }} This challenge categories are
<v-chip v-for="category in challenge.categories" :key="category" density="compact" label class="mr-2 mb-2" @click="$router.push({ path: `/categories/${category}` })">
{{ category }}
</v-chip>
</p>
</blockquote>
</v-col>
<v-col cols="12" lg="6">
<ChallengeTimeline :challenge="challenge" />
</v-col>
</v-row>

<ChallengeExample :exampleId="challenge.exampleId" />

<v-divider :thickness="2" class="border-opacity-100 mt-5 mb-3" />

<v-row>
<v-col cols="12" md="6">
<h2 class="text-h6 mb-1">
General stats
</h2>

<v-row>
<v-col cols="6">
<StatCard :value="challenge.numberOfContributions" :subtitle="'Total number of prices added'" />
</v-col>
<v-col cols="6">
<StatCard :value="challenge.numberOfContributors" :subtitle="'Total number of contributors'" />
</v-col>
</v-row>
</v-col>

<v-col cols="12" md="6">
<h2 v-if="username" class="text-h6 mb-1">
Your stats
</h2>
<v-row v-if="username">
<v-col cols="6">
<StatCard :value="challenge.userContributions" :subtitle="'Prices added by you'" />
</v-col>
<v-col cols="6">
<v-card :title="`${challenge.userRank == 0 ? '50+' : challenge.userRank} / ${challenge.numberOfContributors}`" :subtitle="'Your rank'" variant="tonal" density="compact" />
</v-col>
</v-row>
</v-col>
</v-row>

<v-row>
<v-col cols="12" md="6">
<ChallengeTopContributors :topContributors="challenge.topContributors" />
</v-col>

<v-col v-if="challenge.latestContributions.length" cols="12" md="6">
<h2 class="text-h6 mb-1">
Number of contributions per day
</h2>
<PriceChart :priceList="challenge.latestContributions" aggregate="count" dateField="created" />
</v-col>
</v-row>

<h2 class="text-h6 mb-1">
Most recent contributions
</h2>
<v-row v-if="challenge.latestContributions">
<v-col v-for="price in challenge.latestContributions.slice(0, 10)" :key="price" cols="12" sm="6" md="4" xl="3">
<PriceCard :price="price" :product="price.product" elevation="1" height="100%" />
</v-col>
</v-row>
</template>

<script>
import { defineAsyncComponent } from 'vue'
import constants from '../constants'
import api from '../services/api.js'
import { mapStores } from 'pinia'
import { useAppStore } from '../store'

export default {
components: {
StatCard: defineAsyncComponent(() => import('../components/StatCard.vue')),
PriceCard: defineAsyncComponent(() => import('../components/PriceCard.vue')),
PriceChart: defineAsyncComponent(() => import('../components/PriceChart.vue')),
ChallengeTimeline: defineAsyncComponent(() => import('../components/ChallengeTimeline.vue')),
ChallengeExample: defineAsyncComponent(() => import('../components/ChallengeExample.vue')),
ChallengeTopContributors: defineAsyncComponent(() => import('../components/ChallengeTopContributors.vue')),
},
data() {
return {
challenge: {
title: "MILK",
subtitle: "(and milk alternatives)",
icon: "🥛",
startDate: "2024-09-01",
endDate: "2024-10-31",
categories: ["en:milk-substitutes", "en:milks"],
topContributors: [],
numberOfContributors: 0,
numberOfContributions: 0,
latestContributions: [],
userContributions: 0,
userRank: 0,
exampleId: 32900
},
loading: false,
}
},
computed: {
...mapStores(useAppStore),
username() {
return this.appStore.user.username
},
},
mounted() {
this.getStats()
},
methods: {
getStats() {
this.loading = true
// TODO: This should fetch only prices matching one of the product categories in this.challenge.categories
// TODO: Also, this is both used to show a few recent contribution and to display the number of contributions per day
// The first requires only a size of 10, the second requires the entire data, so it probably should be another API point
api.getPrices({ size: 200, created__gte: this.challenge.startDate, created__lte: this.challenge.endDate })
.then((data) => {
this.challenge.latestContributions = data.items
this.challenge.numberOfContributions = data.total
this.loading = false
})

// TODO: this should only fetch users that contributed to one of the product categories in this.challenge.categories, in the designated time range
api.getUsers({ order_by: constants.USER_ORDER_LIST[0].key, size: 50})
.then((data) => {
this.challenge.topContributors = data.items
this.challenge.numberOfContributors = data.total
for (let i = 0; i < data.items.length; i++) {
const user = data.items[i]
if (this.username && this.username == user.user_id) {
this.challenge.userRank = i
break
}
}
})
if (this.username) {
// TODO: This should fetch only prices matching one of the product categories in this.challenge.categories
api.getPrices({ owner: this.username, created__gte: this.challenge.startDate, created__lte: this.challenge.endDate })
.then((data) => {
this.challenge.userContributions = data.total
})
}

}
}
}
</script>
Loading