Il 29 settembre è stato il giorno della convention di Fortitude Group. È stata una giornata memorabile e non riesco nemmeno a descrivere la gioia che ho provato nel passare finalmente del tempo insieme a tutti i colleghi sparsi in tutta Italia (che di solito vedo solo attraverso un monitor).
Ma facciamo un passo indietro, precisamente a due settimane prima, quando arriva la mail HR con l'agenda ufficiale della convention.
La voglia di vederci e fare qualcosa insieme è sempre altissima e, guardando il programma, ci accorgiamo che c'era uno slot di 3 ore di "tempo libero" con un asterisco: "Possibilità di usare piscine e campi da volley, calcetto e ping pong". Nel giro di pochi minuti dalla mail ricevo un messaggio da Luigi che propone di organizzare squadre e partite per i vari sport.
Il giorno dopo la situazione era già sfuggita di mano e insieme a Gabri decidiamo di organizzare qualcosa di più strutturato:
i Fortigames! Una sfida di due ore tra due squadre che si affrontano contemporaneamente a calcio, volley e ping pong.
Per rendere l'evento più interessante, decidiamo di dare un nome alle squadre. Dopo un po' di brainstorming scegliamo il simbolo di Yin e Yang per via delle classiche magliette bianche e nere. Con un pizzico di creatività in più, diventano Tigers (Yin) contro Dragons (Yang)!
Più info su Yin and Yang / Tigers and Dragons
Creiamo un form su Google Forms per raccogliere i dati dei partecipanti e, nello stesso momento, chiediamo al team HR la lista completa dei partecipanti alla convention con azienda e email.

A questo punto potresti chiederti: "ma il blog di Daniel non è di solito pieno di articoli tecnici?" Hai ragione! Infatti, dopo questo preambolo, ti porto a una settimana dall'evento, precisamente a venerdì 22 settembre.
Dal nulla, Gabri propone di sviluppare un'app "companion" per la convention, focalizzata sui Fortigames e utile per gestire risultati, indicazioni su dove andare, cosa fare, ecc.
All'inizio pensiamo sia uno scherzo: fare un'app da zero in meno di una settimana, soprattutto nel tempo libero, è impensabile. Poi si unisce anche Stefano, che propone di usare tecnologie mai provate prima, e lì non ho saputo resistere.
Decidiamo quindi di sviluppare una PWA con queste tecnologie:
Il venerdì sera inizio già a creare il progetto su Supabase e a installare Qwik con plugin per Supabase.
npm create qwik@latest npm install @supabase/supabase-js supabase-auth-helpers-qwik
Attiviamo l'autenticazione Google dal progetto Fortigames nella dashboard Supabase

impostiamo la return url del provider

e creiamo la web app su Google Cloud (con la return URL corretta)

Per loggare l'utente, basta creare un bottone che al click chiama la funzione createClient di Supabase impostando:
Google come providerAl resto pensa Supabase (in pratica legge il token dalla URL e lo salva in 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>; });
Ci diamo appuntamento alle 9:00 per buttare giù idee e parlare di grafica e funzionalità.
Il primissimo wireframe dell'app:

Dopo aver ragionato sulle feature, partiamo dalla creazione DB direttamente su Supabase. Avendo poco tempo, semplifichiamo un po' la struttura per accelerare il lavoro.

Lista utenti che possono accedere all'app.
Informazioni sull'agenda della giornata. Ogni riga corrisponde a un'attività con ora di inizio e fine.
Informazioni sui risultati delle partite.
Ogni riga corrisponde al risultato di uno sport specifico (soccer, volleyball, table_tennis).
Informazioni sullo stato dei giochi: start/end pianificato, start/end reale, flag per partita in pausa (es. meteo o problemi organizzativi), e ovviamente nome del vincitore.
Non abbiamo pensato a una versione desktop (o totalmente responsive) per accelerare.
Dal classico approccio "mobile first" siamo passati a qualcosa che definirei "mobile only" 😀
(in pratica abbiamo messo una max-width e gestito la responsive solo per smartphone/tablet, senza rivedere tutta la struttura pagina).
Così appariva la prima versione funzionante:

Per velocizzare, abbiamo riusato i file SCSS del sito danielzotti.it.
Per questi motivi:
La struttura è questa, con un ruolo specifico per ogni file:
h1,..., h6, p, a, ul, code).table-container per overflow tabelle)@import dei font usatispacing, html-max-width, ...@media() non supporta ancora variabili css)Verso le 15:00 arriviamo al punto in cui:
La developer experience di Supabase è davvero ottima. Oltre alla documentazione ben fatta e all'SDK utile, c'è anche una cli che semplifica molto la vita.
Per usarla basta installarla:
npm i supabase --save-dev
genera un personal access token:
ed esegui il login:
supabase login
Per chi, come me, ama TypeScript, la cli permette di generare automaticamente i tipi DB:
supabase gen types typescript --project-id {project_id} > src/types/database.types.ts
Supabase permette di gestire policy direttamente nel DB, ma il tempo stringe e non c'è un metodo pronto per filtrare utenti autenticati per dominio.
Quindi scegliamo la via semplice: filtro client-side, controllando solo che l'utente esista nella tabella users.
Supabase gestisce facilmente il login Google, ma mantenere la sessione è responsabilità dello sviluppatore.
Infatti, leggere ogni volta i dati utente da localStorage dà pessime performance e inoltre non era chiaro quando il JWT venisse effettivamente scritto e disponibile.
All'inizio usavamo il classico setTimeout da 500ms, ma ovviamente era ancora meno performante perché dovevamo aspettare ogni volta.
Quindi decidiamo di gestire manualmente il salvataggio sessione in localStorage.
Il flusso:
Login/auth che legge il token dai parametri URLlocalStorageuseAuth) per usare il context come fonte dati principaleCosa succede se ricarichiamo la pagina (dopo login riuscito)?
useCheckSession() nel layout che contiene tutte le pagine protetteuseCheckSession controlla internamente se il context esistelocalStorage// /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: una cosa non immediata in Qwik è che il codice può essere eseguito lato client o lato server (con logiche diverse).
Nel nostro caso dovevamo fare il check lato client (dove persiste il JWT), quindi abbiamo usato useVisibleTask$ per assicurarci l'esecuzione browser-side dopo il primo rendering.
Infatti, useVisibleTask$() è simile a useTask$() ma gira solo nel browser e dopo il rendering iniziale.
Restano ancora molte feature da sviluppare, e si aggiunge Erik (Frontend Developer) al team per darci una mano su alcuni componenti.
Un countdown con questa logica:
In più mostriamo evento agenda corrente e prossimi eventi.
Per accelerare abbiamo creato componenti riusabili: button, back-to-top, logo aziendale, back button, ecc.
Il deploy su Vercel con Qwik è davvero semplice. C'è un adapter che installi con:
npm run qwik add vercel-edge
Poi vai sul sito Vercel e colleghi il repo Git.

Inoltre puoi configurare il deploy automatico al push su uno specifico branch (Settings → Git).

Dopo il push su deploy-vercel, vai su https://fortigames.vercel.app e vedi l'app funzionante.
Il pattern URL è https://{project_name}.vercel.app
Supabase ha la funzione Realtime ed è facilissima da attivare: vai nei dettagli tabella, clicchi il pulsante e hai finito.

Fact "divertente": Stefano aveva attivato il realtime solo sulla tabella users. Quando ho iniziato a sviluppare la parte risultati realtime, ho perso 2 ore per capire che il realtime non era attivo anche sulla tabella giusta. Supabase non dava errore: semplicemente ritornava dati vuoti. Quindi con zero errori ci ho messo un po' a capirlo (qui potrebbero migliorare parecchio!!).
I dati nel DB erano pochi, quindi da un punto di vista realtime aveva senso caricare tutta la lista utenti (e gli altri dataset) senza paginazione e poi restare in ascolto dei pochi cambiamenti aggiornando la memoria. Per comodità abbiamo incapsulato la logica in hook.
NB: prendiamo useParticipants() come esempio, ma gli altri hook seguono lo stesso pattern.
L'idea è inizializzare lo store dell'hook nel layout delle pagine protette (dato che l'app funziona solo dopo autenticazione), così i dati vengono caricati una volta sola e comunque dopo auth.
La single source of truth è lo store.
Da lì filtriamo i dati di interesse (es. utenti che partecipano ai giochi perché assegnati a un team, oppure chi gioca a boardgame).
Ascoltiamo i cambi realtime DB e aggiorniamo lo store, e tutte le proprietà useComputed$ si aggiornano automaticamente.
// /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, }; };
Con il realtime pronto, lavoriamo all'aggiornamento score partite. Operazione semplice: "+1" o "-1" al dato quando si clicca il bottone. Unica accortezza: disabilitare subito il bottone dopo il click, per evitare update multipli.
// /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>;
Sono le 01:40. L'app sembra usabile e (quasi) completa. Possiamo andare a dormire, ma prima foto di rito per festeggiare!

Per avere qualcosa di funzionante il giorno della convention, abbiamo lasciato per ultime le componenti di gestione partite, perché in emergenza si poteva comunque intervenire direttamente nel DB da Supabase.
Fortunatamente c'era ancora un giorno e, prendendomi un paio d'ore nel pomeriggio, sono riuscito a gestire i pulsanti per start, pause e restart.
Ogni pulsante segue la stessa logica: legge in realtime lo stato giochi e, al click, esegue l'operazione specifica Start/Stop/Reset.
A parte la grafica (SVG coppa disegnato da Gabri), il componente resta in ascolto del campo winner nella tabella config.
Quando il valore diventa "dragons" o "tigers", il componente mostra la coppa con il nome del vincitore.
Sono le 20:00 del giorno prima della convention. La partenza è alle 6:00 del mattino (con 3 ore di macchina!) e il debito di sonno va recuperato in qualche modo (andare a letto tutti i giorni alle 2:00 forse non è stata un'idea brillante 😅).
Giusto il tempo di qualche fix grafico finale, ZERO testing, ultimo deploy e si spera nella fortuna! Tempo di fare la valigia e provare a riposare per i due giorni di evento.
Un ultimo sguardo all'evoluzione della UI prima di chiudere il viaggio:

Ok, il tempo era davvero poco, ma siamo riusciti a sviluppare questa app! Certo, continuare ad aggiungere idee e feature in corsa non è stata la scelta migliore, però alla fine ce l'abbiamo fatta e ne è valsa la pena.
Non smetterò mai di dirlo: se ti diverti e ci metti passione, puoi fare qualsiasi cosa.
Facciamo il punto sulle scelte:
Supabase offre una bella dev experience e documentazione molto ben fatta. Di contro, il realtime ci è sembrato un po' lento rispetto a Firebase
In generale siamo contenti di com'è andata, anche perché abbiamo usato Qwik e Supabase per la prima volta e, in pochi giorni, siamo riusciti a creare un'app funzionante con poco sforzo. Sono due strumenti che terremo presenti anche per progetti più complessi, facendo però un approfondimento serio.
Ci sono sicuramente miglioramenti possibili. Non so se li faremo, ma li elenco:
config direttamente da UITi lascio il link al repo GitHub, così puoi navigare il codice. Se vuoi provare l'app, ti basta usare il repo e seguire gli step per creare il tuo DB su Supabase e la tua app su Google Cloud console.
Dovrai accontentarti di un video, sorry...
Questo è stato un post lunghissimo... Direi che ci ho messo più tempo a scrivere l'articolo che a sviluppare davvero l'app! 😅 Spero che il tutorial sia chiaro e utile a qualcuno. E come sempre, se hai domande o vuoi maggiori dettagli su una parte specifica, scrivimi pure! 🙃
❤️ Grazie per aver letto fin qui! ❤️