Fortitude Convention Companion App
September 29 was the day of the Fortitude Group's convention. It was a truly memorable day and I can't even describe the joy I felt finally spending time together with all the colleagues who work scattered around Italy (and whom I usually see only through a monitor).
But let's go back a bit, to two weeks earlier to be precise, when the email from HR with the official agenda for the convention arrived.
Thursday 14th September
The desire to see each other and do something together is always huge, and after looking at the schedule, we realized that there was a 3-hour "free time" slot with an asterisk next to it that said "Possibility of using the pools and the volleyball, soccer and table tennis facilities". Within minutes after receiving the email I got a message from Luigi proposing to arrange teams and matches for the respective sports.
The next day, things have already gotten out of hand and together with Gabri we decide to organize something more complex:
the Fortigames! A two-hour challenge between two teams that will compete simultaneously playing soccer, volleyball and table tennis.
To make the event more interesting, we decided to name both teams. After brainstorming, we opted for the symbol of Yin and Yang because of the classic black and white T-shirts. With a little more creativity, we decided on Tigers (Yin) versus Dragons (Yang)!
More info about Yin and Yang / Tigers and Dragons
Tuesday 19th September
We create a form on Google Forms to collect participants data and at the same time, ask the HR team for the full list of convention attendees with their company info and emails.
That being said, you may ask, "isn't Daniel's blog usually filled with technical posts?" You are right! In fact, after this preamble, I'll take you to a week before the event, specifically Friday, September 22.
Friday 22nd September
Out of the blue, Gabri comes up with the idea of developing a convention "companion" app, mostly focusing on the Fortigames and convenient for managing the results and helping attendees figure out where to go, what to do, etc.
Initially we think it's a joke; making an app from scratch in less than a week, especially working in our spare time, is unthinkable. But then Stefano joins in as well, and he proposes to use some technology never tried before, and I couldn't resist.
So we decide to develop a PWA using the following technologies:
- Balsamiq: for wireframe
- Figma: for the UI
- Qwik: as the main framework
- Font Awesome for icons
- SCSS structure copy-pasted from my website with CSS modules
- Supabase: realtime database with Google authentication (since every Fortitude Group employee has their own Google corporate account)
- GitHub: to save the open source code
- Vercel: for the deployment
Project creation
On Friday night, I already started creating the project on the Supabase website and installing Qwik, with its plugin for Supabase.
npm create qwik@latest
npm install @supabase/supabase-js supabase-auth-helpers-qwik
Google Authentication setup
Activating authentication via Google from the Fortigames project on the Supabase dashboard
set the return url for the provider
and create the web app on Google Cloud (with the correct return URL)
To log the user in, simply create a button that, when clicked, calls Supabase's createClient
function by setting:
-
Google
as the provider - entering as parameters
- the project URL
- the key we find on the Supabase site
Supabase does the rest (it basically takes care of reading the token data from the URL and saving it to the localStorage)
// /src/components/auth/login/login.tsx
export const Login = component$(() => {
const location = useLocation();
const handleGoogleLogin = $(async () => {
createClient<Database>(
import.meta.env.PUBLIC_SUPABASE_URL || '',
import.meta.env.PUBLIC_SUPABASE_ANON_KEY || ''
).auth
.signInWithOAuth({
provider: 'google',
options: {
redirectTo: location.url.origin + config.urls.auth
}
});
});
return <Button onClick$={handleGoogleLogin}> Login with Google </Button>;
});
Saturday 23rd September
We agree to meet Saturday morning at 9 a.m. to start jotting down ideas and talk about graphics and features.
The team
- Stefano: Head of UX/UI Engineering
- Gabriella: Team Leader UX/UI
- Daniel: Team Leader Frontend
App Features
- Dashboard page: with realtime results and schedule, countdown start/end games and location map (+ cup with winner at the end)
- Teams page: list of team members + filters by team and sport type
- Games page: results of the 3 sports + section only for referees, facilitators and admin to score and start/stop games
- Boardgames page (yes, those who don't want to play sports can have fun playing with boardgames!) with list of available games and some additional info
- Info page: rules, agenda, location, etc..
- Profile page: with custom icon based on team membership and links to dedicated chats on Slack for team, facilitators, boardgamers, etc...
- Admin section: to manage convention registrants and Fortigames participants.
Wireframe
The very first app wireframe:
Database
After reasoning about the features, we begin from the creation of the DB directly on the Supabase website. Having little time, we try to simplify the structure a bit so as to speed up the work.
Users
List of users who can access the app.
- Personal information (first name, last name, company)
- Info about roles (admin, facilitator, referee)
- Info about games (team affiliation, game participation)
- Email: we use this field to associate the person's info with the user authenticated through Google
Agenda
The day's agenda information. Each row corresponds to a task and has a beginning and an ending time.
Games Results
The information about the results of the various games. Each row corresponds to the result of a specific sport (soccer
, volleyball
, and table_tennis
)
Config
The information about the status of the games: planned start/end time, actual start/end time, a flag to manage the paused game (e.g., bad weather or not plannable problems) and, of course, the name of the winner!
Layout
We didn't think about a desktop version (or at least totally responsive) so as to speed up the work. From the usual "mobile first" approach, you could say that we switched to a more simple approach that I would call "mobile only" ๐ (basically we put a max-width
and handled responsive only for smartphones and tablets, without revising the page structure).
This is how the very first working version of the app looked like.
SCSS file structure
To speed up the work, we used the SCSS files from the danielzotti.it website.
The reasons are as follows:
- No UI frameworks are used
- It is modular
- It already has reset styles included
- It works with CSS variables
- It supports dark/light theme
- It already contains some basic styles
The structure is as follows and each file has its own purpose:
-
base: styles of basic html tags such as
h1
,...,h6
,p
,a
,ul
,code
-
common: common styles used in different contexts but which are not base tags. (e.g.
.table-container
to handle overflow of tables) -
fonts:
@import
of the various fonts used - layout: responsive styles similar to Bootstrap: container, breakpoint, etc...
- reset: reset of all basic styles
- theme: the variables for dark/light themes
-
variables-css: the general and theme-independent variables:
spacing
,html-max-width
, ... -
variables-scss: the SCSS variables (unfortunately
@media()
does not support css variables yet, so we had to keep the SCSS variables to use them in that context) - components (folder): styles of reusable components such as buttons, icons, ...
- mixins (folder): reusable mixins within the application.
Around 3pm we get to the point where:
- the layout structure is ready and supports the dark/light theme
- we are able to authenticate through Google
- we have imported the convention participant data via CSV (directly on the Supabase website)
- we are able to read the data from the users table
- we have the ability to use icons conveniently thanks to FontAwesome
Monday 25th September
Supabase CLI
The developer experience of Supabase is really nice. In addition to the well-written documentation and useful SDK, there is also a cli to make life easier for developers.
To use it, just install it
npm i supabase --save-dev
generate a personal access token.
and login
supabase login
Supabase and TypeScript
And for those who, like me, love working with TypeScript, the cli provides a feature to automatically generate DB types:
supabase gen types typescript --project-id {project_id} > src/types/database.types.ts
User filter
Supabase has the ability to manage policies within the DB, but time is short and there is no ready method for filtering authenticated users by domain, so we decide to go the easy way and filter users client-side only, simply checking to see if they are in the users
table.
Authentication session
Supabase handles the authentication through Google easily, but maintaining the session is the responsibility of the app developer.
In fact, reading the user's data from the localStorage
every time causing bad performances and, in addition, there is no way (or we didn't find it) to figure out when the JWT token is actually saved in the localStorage and can finally be read and saved in memory. Initially, we used the good old setTimeout
of 500ms
but of course it became even less performant because we had to wait for the timeout each time we needed to read it.
As a result, we decided to manually handle saving the data in the localStorage
.
The flow is as follows:
- The user clicks on the
Login
button. - He/She is sent to the classic Google Login page
- Once logged in, he is redirected to the
/auth
page that will take care of reading the token from the URL parameters - with a call to the DB, data is read from the users table (filtered for the email with which the user has authenticated)
- if the user does not exist in the users table, an error page is shown because the user is not in the list of users authorized to use the app
- if the user exists, we enrich the information taken from the users table to the session data
- save the session data in the
localStorage
- save the session data in the context (using the
useAuth
hook) so that we can directly use the context and not thelocalStorage
as the data source (single source of truth) - the user is eventually redirected to the home page (dashboard)
What happens if we reload the page (after successfully logging in)?
- We added the
useCheckSession()
hook on the layout that encapsulates all protected pages and returns the session -
useCheckSession
internally checks if the context exists - If the context exists, it returns it immediately
- If the context does not exist:
- it goes to check if a token exists in the
localStorage
- if the token does not exist, the user is redirected to the login
- if the token exists, it copies the information into the context and returns the newly updated context
// /src/hooks/useCheckSession.ts
export function useCheckSession() {
const navigate = useNavigate();
const { auth } = useAuth();
const isTokenExpired = $(({ expires_at }: AuthSession) => {
if (!expires_at) {
return false;
}
return new Date() >= new Date(expires_at * 1000);
});
const getSessionFromLocalStorage = $(async () => {
try {
const tokenString = localStorage.getItem(config.jwtTokenLocalStorageName);
if (!tokenString) {
return null;
}
const token: AuthSession = JSON.parse(tokenString);
if (await isTokenExpired(token)) {
return null;
}
return token;
} catch (e) {
return null;
}
});
useVisibleTask$(async () => {
if (auth.value) {
return;
}
const token = await getSessionFromLocalStorage();
if (!token) {
navigate(config.urls.login);
return;
}
auth.value = token;
});
return auth;
}
NB: One thing that is not immediate to understand about Qwik at first is that code can be executed either client or server side (based on different logics).. In our specific case, we needed to execute the client-side check (where the JWT token session persists) and thus we used useVisibleTask$
to make sure that the client-side code was executed, after the first rendering of the component.
In fact, the useVisibleTask$()
is similar to useTask$()
but it only runs on the browser and after initial rendering.
Tuesday 26th September
There are still many features to be developed, and
Erik is added to the developer team to help us out with a couple of components.
Time manager
A countdown that follows this logic:
- Before the games start, the countdown points to the scheduled start date of the games
- When the games have started, the countdown points to the expected end date of the games
- When the games are finished, the countdown points to the actual end date of the games
In addition, the current event on the agenda and upcoming events are shown.
Minimal UI kit
In order to speed up the development, we created a couple of reusable components: button, back to top button, company logo, back button, etc.
First deploy on Vercel
Deploying to Vercel is really straightforward with Qwik, in fact there is an adapter that you simply install with
npm run qwik add vercel-edge
and then just go to the Vercel website and connect the Git repo
In addition, you can configure the project to automatically deploy on "push" on a specific branch (Settings โ Git
)
After pushing to the deploy-vercel
branch just go to https://fortigames.vercel.app and see your app working.
The pattern for the URL is https://{project_name}.vercel.app
Wednesday 27th September
Time for Real Time!
Supabase has the Realtime function and it is really easy to activate: just go to the table details, click on the button and you're done!
"Fun" fact: Stefano was in charge of studying the realtime part and, initially, he had activated that feature only for the users table. When I set out to develop the code for the realtime results, I lost 2 hours to realize that realtime had not been enabled on that table as well. In fact, Supabase does not return an error, but simply empties data so, with no error, it took me a while to figure it out (they could definitely improve this!!).
There is little data in the DB, and from a realtime perspective it makes sense to load the whole list of users (and all the other data) without thinking about pagination and just stay listening for the few data changes and update the data in memory accordingly. For convenience of use, we decided to wrap the logic in hooks.
hooks for realtime
NB: We will take useParticipants()
hook as an example, but all the hooks are developed pretty much the same way (following the same pattern).
The idea is to initialize the hook's store data in the protected pages layout (since the application works only after authentication), so data is taken only once and, in any case, after authentication.
The single source of truth is our store, and from there we filter the data we are interested in, such as the list of users who participate in games (those who are associated with a team), or the list of people who play board games. We listen for realtime changes in the DB and update the store accordingly, and all other useComputed$
properties will update automatically.
// /src/hooks/useParticipants.ts
export const useParticipants = () => {
// Single source of truth
const store = useContext(ParticipantsContext);
// List of all people in Fortitue Group who participate to the convention
const usersList = useComputed$<Participant[]>(() => {
return Object.values(store);
});
// Participants in a team
const participantsList = useComputed$<Participant[]>(() => {
return Object.values(store).filter((p) => !!p.team);
});
// People who play boardgames
const boardgamersList = useComputed$<Participant[]>(() => {
return Object.values(store).filter((u) => u.is_playing_boardgames);
});
// Get participant by a specifica email (ID)
const participantByEmail = $((email: string) => store[email]);
// It is used in the layout of the protected route
const initializeContext = $(async () => {
const { data } = await supabaseClient.from("users").select("*");
// Save data in key:value structure (email is the key)
if (data?.length) {
Object.entries(data).forEach(([key, value]) => {
store[value.email] = value;
});
}
// Listen to data changes
supabaseClient
.channel("custom-update-channel")
.on(
"postgres_changes",
{ event: "UPDATE", schema: "public", table: "users" },
(payload: RealtimePostgresUpdatePayload<Participant>) => {
store[payload.new.email] = payload.new;
},
)
.subscribe();
});
return {
initializeContext,
store,
participantByEmail,
participantsList,
usersList,
boardgamersList,
};
};
Manage score
Once the realtime feature is ready, we can work on updating the score of the matches. This is a fairly straightforward operation: just do "+1" or "-1" on the data when you click on the appropriate button (kind of like the classic โcounterโ example). One thing to keep in mind is to immediately disable the button after the user clicked, so as to avoid multiple updates.
// /src/routes/(protected)/games/[id]/index.tsx
const updateScore = $(
async ({ team, score }: { team: 'dragons' | 'tigers'; score: number }) => {
if (!result.value) {
return;
}
if (score < 0) {
return;
}
isSubmitting.value = true;
try {
const row = {
...result.value,
last_update: new Date().toISOString(),
[team]: score
};
} catch (ex) {
alert('There was an error :(');
} finally {
isSubmitting.value = false;
}
}
);
<Button
variant='selected'
disabled={isSubmitting.value}
onClick$={() =>
updateScore({
team: 'dragons',
score: result.value!['dragons'] + 1
})
}
>
+</Button>;
Almost done
It's 1:40am. The app seems usable and (almost) complete. We can go to sleep now, but first let's take a picture to celebrate!
Thursday 28th September
Start/Stop Fortigames
In order to have something up and running on the day of the convention, we left the components for managing the games for last, since this could be handled by editing the DB directly anyway (from the Supabase website).
Fortunately, there was still a day to go, and by taking just a couple of hours off in the afternoon I was able to manage the buttons to start games, pause and restart them.
Each button has the same logic: it reads the status of the games in realtime and, when clicked, handles the specific Start/Stop/Reset operation of the games.
Winner page
Graphics aside (an SVG of the cup drawn by Gabri), the component keeps listening to the winner
field of the config
table and, at the time it is set with the value "dragons" or "tigers", the component displays the trophy with the name of the winner.
Last moments
It's 8pm of the day before the convention. The next day's departure is at 6am (and a 3-hour drive!) and the sleep backlog has to be made up somehow (going to bed every day at 2am was probably not a good idea ๐ ).
Just time for a few last graphical fixes, ZERO testing, and the last deployment is done hoping for good luck!
Time to pack up and try to rest up for the two-day event!
One last look at the evolution of the UI before concluding our journey:
Final thoughts
Okay, the time was really short but we made it develop this app! Sure, adding ideas and features along the way was not a good idea, but in the end we did it and it was worth it.
I will never stop saying it, if you have fun and put passion into it you can do anything.
Let's take stock of the choices made:
- Knowing that the app is used by trusted people and only internally, we did not take large security controls into account
- The app was created just for fun, so we didnโt care about performances and chose instead to add more features.
- We were so focused on functionality that we did not have time to focus on shared development patterns (ES: DB access mode, folder and file structure, naming conventions, ...)
- We did not study Qwik or Supabase in a structured way. We went ahead by trial and error and reading only the part of the documentation that could be useful to us (usually this is not the best way to study)
- Qwik is nice but it is definitely different from known frameworks, although JSX, hooks, routes are also used in other frameworks. At first you feel something "strange" and you bang your head on it but then, once you understand its logic, I must say it is a pleasure to develop with it.
- Supabase has a nice dev experience and a really well written doc. On the other hand, realtime feature seemed a little slow compared to Firebase.
- Overall we are happy with how it went, partly because we used both Qwik and Supabase for the first time and, in a few days, we were able to develop a working app with very little effort. These are two tools that we will keep in mind also for more complex projects, taking advantage of doing a deep dive to study them properly.
Next steps / Improvements
Of course there are certainly improvements that can be made. I don't know if we will do them but Iโd like to list them:
- Improving DB security configuring Supabase Policies.
- Adding a toggle theme button to select the preferred theme (currently, the default OS theme is used)
- Improving the UI of the admin section
- Managing the
config
table directly from UI - Code cleanup: revise folder structure a bit, clean up comments and unused code, use shared patterns in various pages/components
- โฆand more that I can't think of now!
The Code
I leave the link to the GitHub repo so you can browse through the code. If you want to try using the app you just need to use the repo code and follow the steps to create your own DB on
Supabase and your own app on Google Cloud console.
The Demo
You'll have to settle for a video, I'm sorry...
Wrap-up
This was a very long post... I would say it took more time to write the article than the actual development work! ๐
I hope the tutorial is clear and useful for someone. And, as usual, if you have questions or you need more information
about a specific part, don't hesitate to leave a comment! ๐
โค๏ธ Thanks for reading it! โค๏ธ
Top comments (0)