mirror of
https://github.com/maciejpedzich/maciejpedzi.ch.git
synced 2024-11-27 23:55:47 +01:00
Publish the final devlog for RaceMash
This commit is contained in:
parent
0923bf4bef
commit
397db98cad
@ -0,0 +1,263 @@
|
|||||||
|
---
|
||||||
|
title: progress bar, trivia stops, and ranking page in racemash
|
||||||
|
description: Find out how I've implemented
|
||||||
|
pubDate: 2023-07-06T07:55:12.475Z
|
||||||
|
draft: true
|
||||||
|
categories:
|
||||||
|
- dev diary
|
||||||
|
tags:
|
||||||
|
- formulaone
|
||||||
|
- racemash
|
||||||
|
- vue
|
||||||
|
- vuetify
|
||||||
|
---
|
||||||
|
|
||||||
|
Hey!
|
||||||
|
|
||||||
|
I know it's been a couple weeks since my last RaceMash post, although I have anything but abandonned this project. Better yet, I actually released it yesterday. You can [go to racemash.netlify.app](https://racemash.netlify.app) to check out the app in action, or [browse its source code on GitHub](https://github.com/maciejpedzich/racemash).
|
||||||
|
|
||||||
|
Side note: I've actually opted not to use the `racemash.com` domain name, because I now consider this project a small but not borderline basic demo, rather than a product of sorts, so I didn't really feel like setting up and annually renewing a dedicated domain name for it.
|
||||||
|
|
||||||
|
But this was supposed to be a post describing the implementation of the last two core features I was yet to implement at the time of publishing of my last post, so let's just jump into it already.
|
||||||
|
|
||||||
|
## Adding a progress bar
|
||||||
|
|
||||||
|
Vuetify had already got me covered with [an appropriate component](https://vuetifyjs.com/en/components/progress-linear/). I just needed to supply it with a percentage value, which in my case was 100 times the ratio of the votes submitted by the user so far to all the possible votes that can be casted.
|
||||||
|
|
||||||
|
As I estabilished in [the first post of this series](/racemash-my-appwrite-x-hashnode-hackathon-project/#how-is-this-app-supposed-to-work), the number of all votes a user can submit given a set of `n` unique images equals _n choose 2_, which can be simplified to `(n * (n - 1)) / 2`. I made use of Vue 3's `computed` to keep the percentage up-to-date as the number of votes submitted by the user changed.
|
||||||
|
|
||||||
|
This is how I modified my `useVote.ts` file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ...
|
||||||
|
|
||||||
|
const NUM_POSSIBLE_VOTES = (db.photos.length * (db.photos.length - 1)) / 2;
|
||||||
|
|
||||||
|
const completionPercentage = computed(
|
||||||
|
() => (db.votes.length / NUM_POSSIBLE_VOTES) * 100
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
export function useVote() {
|
||||||
|
return {
|
||||||
|
// ...
|
||||||
|
completionPercentage,
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All that was left to do was placing Vuetify's progress bar component, plugging `completionPercentage` computed property into the `model-value` prop, applying some styling touch-ups and having the bar display 2 decimal places, because it ensures constant value updates as one vote fills up the progress bar by about `0.36` percentage points.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script lang="ts" setup>
|
||||||
|
// ...
|
||||||
|
|
||||||
|
const {
|
||||||
|
// ...
|
||||||
|
completionPercentage,
|
||||||
|
// ...
|
||||||
|
} = useVote();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ... -->
|
||||||
|
<div class="w-100 px-4">
|
||||||
|
<v-progress-linear
|
||||||
|
class="px-6"
|
||||||
|
color="primary"
|
||||||
|
height="20"
|
||||||
|
:model-value="completionPercentage"
|
||||||
|
>
|
||||||
|
<template v-slot:default="{ value }">
|
||||||
|
<strong>{{ value.toFixed(2) }}%</strong>
|
||||||
|
</template>
|
||||||
|
</v-progress-linear>
|
||||||
|
</div>
|
||||||
|
<!-- ... -->
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Handling trivia milestones and 100% completion
|
||||||
|
|
||||||
|
With a progress bar out of the way, it was time for me to implement trivia pit stops for submitting 25%, 50%, and 75% of all votes, as well as a congratulations message with a link to the ranking page for reaching the highly coveted 100%. First I had to go back to the `useVote.ts` file to add the following `computed` properties, but also a `shownFactIndexes` field that's an initially empty array of numbers:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const userSubmittedAllVotes = computed(
|
||||||
|
() => completionPercentage.value === 100
|
||||||
|
);
|
||||||
|
|
||||||
|
const userReachedTriviaMilestone = computed(() =>
|
||||||
|
[25, 50, 75].includes(completionPercentage.value)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then I created a `funFacts.json` file that was essentially an array of strings, where each one contained a fun fact. After that I came back to my vote view to implement picking a random fun fact whenever a user reached one of the milestones.
|
||||||
|
|
||||||
|
I added two `ref`s: `funFactToShow` and `canShowFunFact`, which are initally set to be an empty string and `false` respecitvely. Then I placed a `watch` for the `userReachedTriviaMilestone` computed property. If its new value is `false`, I return immediately. Otherwise, I grab a list of indexes of facts that are yet to be shown, pick a random one and use it to get the fun fact to display to finally add it to `shownFactIndexes` array and toggle the `canShowFunFact` flag.
|
||||||
|
|
||||||
|
Here's how a human known as Mac turned this _prompt_ into code:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ...
|
||||||
|
|
||||||
|
import funFacts from '@/funFacts.json';
|
||||||
|
import { randomNumber } from '@/utils/randomNumber';
|
||||||
|
|
||||||
|
const funFactToShow = ref('');
|
||||||
|
const canShowFunFact = ref(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
// ...
|
||||||
|
userReachedTriviaMilestone,
|
||||||
|
shownFactIndexes,
|
||||||
|
// ...
|
||||||
|
} = useVote();
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
watch(userReachedTriviaMilestone, (valueIsTrue) => {
|
||||||
|
if (!valueIsTrue) return;
|
||||||
|
|
||||||
|
const availableFunFactIndexes = [...Array(funFacts.length).keys()].filter(
|
||||||
|
(index) => !shownFactIndexes.value.includes(index)
|
||||||
|
);
|
||||||
|
const funFactIndex = randomNumber(0, availableFunFactIndexes.length - 1);
|
||||||
|
|
||||||
|
funFactToShow.value = funFacts[funFactIndex];
|
||||||
|
shownFactIndexes.value.push(funFactIndex);
|
||||||
|
canShowFunFact.value = true;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
I also had to modify the template to add a couple `v-if`s to toggle between the 100% congratulations message, fun fact pit stop, and the actual voting section.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section class="w-100 h-100 d-flex flex-column justify-center align-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<template v-if="userSubmittedAllVotes">
|
||||||
|
<h1 class="mb-md-5 mb-2 text-md-h2 text-h4">Congratulations!</h1>
|
||||||
|
<p class="mb-md-6 mb-4 px-6 text-md-h5 text-body-1 font-weight-regular">
|
||||||
|
You've submitted all votes. Check out the results by clicking the
|
||||||
|
button below.
|
||||||
|
</p>
|
||||||
|
<v-btn size="large" to="/ranking">Show ranking</v-btn>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="userReachedTriviaMilestone && canShowFunFact">
|
||||||
|
<h1 class="mb-2 text-h3">Trivia pit stop</h1>
|
||||||
|
<div class="mt-3 mb-8 px-12">
|
||||||
|
<p class="text-h6 mb-6 font-weight-regular">
|
||||||
|
You're doing great! While we have a quick pit stop to keep you
|
||||||
|
running smoothly, enjoy this random bit of trivia.
|
||||||
|
</p>
|
||||||
|
<p class="w-50 mx-auto my-0 text-h6 font-italic">
|
||||||
|
{{ funFactToShow }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<v-btn @click="canShowFunFact = false">Continue</v-btn>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<!-- Voting bit goes here -->
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ranking page
|
||||||
|
|
||||||
|
Here comes the final feature to implement - the ranking page you can enter after submitting all votes. I wanted to sort the images by their ratings descendingly and display them in a responsive grid, where each image has a caption in the bottom left corner with the photo's position in the ranking as well as the aforementioned rating. I took advantage of Vuetify's card and grid components, but also the `useDisplay` composable to achieve this.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useDisplay } from 'vuetify';
|
||||||
|
import { useVote } from '@/composables/useVote';
|
||||||
|
|
||||||
|
const display = useDisplay();
|
||||||
|
const { photos } = useVote();
|
||||||
|
|
||||||
|
const photosSortedByRatingDesc = photos.value.sort(
|
||||||
|
(a, b) => b.rating - a.rating
|
||||||
|
);
|
||||||
|
const cols = computed(() =>
|
||||||
|
display.xlAndUp.value ? 3 : display.lg.value ? 4 : display.md.value ? 6 : 12
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="w-100 h-100 d-flex flex-column justify-center align-center">
|
||||||
|
<h1 class="mt-10 mb-3 text-h3">Ranking</h1>
|
||||||
|
<p class="px-4 mb-3 text-h6 font-weight-regular">
|
||||||
|
Here's the final classification of photos based on their ratings:
|
||||||
|
</p>
|
||||||
|
<v-container>
|
||||||
|
<v-row dense no-gutters>
|
||||||
|
<v-col
|
||||||
|
v-for="(photo, index) in photosSortedByRatingDesc"
|
||||||
|
class="px-4 py-4 d-flex justify-center"
|
||||||
|
:key="photo.fileName"
|
||||||
|
:cols="cols"
|
||||||
|
>
|
||||||
|
<v-card max-width="480" max-height="270">
|
||||||
|
<v-img
|
||||||
|
:src="`/images/${photo.fileName}`"
|
||||||
|
:alt="photo.altText"
|
||||||
|
class="align-end"
|
||||||
|
gradient="to bottom, rgba(0,0,0,.1), rgba(0,0,0,.5)"
|
||||||
|
:aspect-ratio="16 / 9"
|
||||||
|
>
|
||||||
|
<v-card-title class="text-white">
|
||||||
|
{{ `#${index + 1} (Rating: ${photo.rating.toFixed(2)})` }}
|
||||||
|
</v-card-title>
|
||||||
|
</v-img>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Now I just needed to add an appropriate route record and a route guard to prevent the user from entering `/ranking` before casting all votes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
NavigationGuardNext,
|
||||||
|
RouteLocationNormalized,
|
||||||
|
createRouter,
|
||||||
|
createWebHistory
|
||||||
|
} from 'vue-router';
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
import { useVote } from '@/composables/useVote';
|
||||||
|
|
||||||
|
const { userSubmittedAllVotes } = useVote();
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
// ...
|
||||||
|
{
|
||||||
|
path: '/ranking',
|
||||||
|
name: 'Ranking',
|
||||||
|
component: () => import('../views/Ranking.vue'),
|
||||||
|
beforeEnter: (
|
||||||
|
_to: RouteLocationNormalized,
|
||||||
|
_from: RouteLocationNormalized,
|
||||||
|
next: NavigationGuardNext
|
||||||
|
) => {
|
||||||
|
if (userSubmittedAllVotes.value) {
|
||||||
|
return next();
|
||||||
|
} else {
|
||||||
|
return next('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
And that was it! RaceMash was, at long last, ready to deploy.
|
||||||
|
|
||||||
|
## Wrapping up
|
||||||
|
|
||||||
|
Thank you so much for reading in once again. For my next solo project, I'll stick to the topic of Formula One, but this time from a dataviz standpoint. I'm really looking forward to developing this one, as I should be done collecting all the aforementioned data this weekend. So stay tuned for my next post and take care!
|
Loading…
Reference in New Issue
Block a user