12 minuti di lettura
Mostrare l’ultima timbratura è utile, questo è chiaro, ma non basta per gestire davvero uno storico. Un dipendente può aver bisogno di controllare una settimana intera, confrontare un mese, verificare una singola giornata, o individuare un evento che sembra mancante.
Per questo l’archivio timbrature deve risolvere quattro problemi distinti tra loro: caricare i dati con un intervallo preciso, indicare all’utente quando il caricamento è in corso, permettere di cambiare il periodo senza ambiguità, e rendere leggibile una risposta raggruppata per giorno.
In questo articolo vediamo un’implementazione React Native basata su useTimestamps, TimestampsList, PeriodFilterModal, FilterModal, e sul contratto applicativo onGetTimestamps
Che cosa deve fare un archivio timbrature
Uno storico utile non è, in fondo, una semplice lista di date allineate. Deve spiegare il contesto dell’evento, e rendere immediatamente riconoscibili le eventuali anomalie.
Per ogni giornata è utile mostrare giorno e mese, una colonna per le entrate, una per le uscite, più coppie di orari se la giornata contiene delle pause, e un indicatore neutro quando manca un evento della coppia.
Nel modello considerato, type distingue ingresso (0) e uscita (1), mentre status consente di escludere eventi non validi. Questi significati devono essere documentati con chiarezza nel contratto: non è affatto buona pratica lasciare che il componente grafico interpreti numeri privi di nome, indovinandone il senso.
L’archivio deve inoltre gestire tre condizioni che spesso vengono confuse tra loro: loading, quando la prima richiesta non è ancora terminata; empty, quando la richiesta è terminata ma non ci sono eventi da mostrare; ed error, quando la richiesta non è andata a buon fine per qualche motivo.
Dal periodo alla richiesta API
Un filtro temporale deve produrre due date coerenti tra loro, normalmente nel formato YYYY-MM-DD. Il flusso può essere rappresentato così: l’utente apre l’archivio, viene applicato un periodo predefinito, useTimestamps prepara startDate ed endDate, viene chiamato onGetTimestamps(startDate, endDate), arriva una risposta raggruppata per data, e TimestampsList la ordina e la renderizza.
Quando l’utente seleziona un nuovo intervallo, il flusso è simile: PeriodFilterModal raccoglie la scelta, chiama onChangeRange({ start, end }), aggiorna lo stato della schermata, avvia una nuova richiesta API, e produce infine un nuovo rendering.
È importante decidere se il periodo va applicato subito, oppure soltanto dopo aver premuto il pulsante Applica. PeriodFilterModal usa la seconda strategia: il calendario modifica uno stato locale, e comunica il range soltanto quando sono presenti entrambe le date.
Le date devono essere normalizzate in un solo punto del codice. In questo progetto useTimestamps usa formatDateToYYYYMMDD, evitando così di inviare stringhe dipendenti dalla localizzazione del dispositivo — un dettaglio che, se trascurato, può creare disallineamenti silenziosi tra client e server.
L’hook useTimestamps
L’hook incapsula stato, caricamento, refresh e chiamata al servizio, tutto insieme:
import { useState, useCallback, useEffect } from "react";
import { formatDateToYYYYMMDD } from "../utils/formatDate";
import { getTimestamps } from "../services/ApiService";
export const useTimestamps = (daysAgo = 1) => {
const [timestamps, setTimestamps] = useState([]);
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(true);
const fetchTimestampsData = useCallback(async () => {
try {
const today = new Date();
const pastDate = new Date(today);
pastDate.setDate(today.getDate() - daysAgo);
const startDate = formatDateToYYYYMMDD(pastDate);
const endDate = formatDateToYYYYMMDD(today);
const response = await getTimestamps(startDate, endDate);
setTimestamps(response);
} catch (error) {
console.error("Errore nel recupero timbrature:", error);
} finally {
setLoading(false);
}
}, [daysAgo]);
const onRefresh = useCallback(async () => {
setRefreshing(true);
await fetchTimestampsData();
setRefreshing(false);
}, [fetchTimestampsData]);
useEffect(() => {
fetchTimestampsData();
}, [fetchTimestampsData]);
return { timestamps, refreshing, onRefresh, loading, fetchTimestampsData };
};
fetchTimestampsData viene usata sia dall’effetto iniziale sia dal refresh manuale. useCallback mantiene stabile la funzione finché daysAgo non cambia, e impedisce all’effetto di ripetersi inutilmente a ogni singolo render — un dettaglio che conta parecchio su una schermata che l’utente può tenere aperta a lungo.
L’API attuale dell’hook è volutamente semplice: riceve quanti giorni andare indietro nel tempo. Per un filtro libero, però, è più espressivo accettare direttamente un range:
const useTimestamps = ({ startDate, endDate, onGetTimestamps }) => {
// carica esattamente il periodo selezionato
};
Questa variante evita di trasformare un intervallo scelto dall’utente in un numero approssimativo di giorni, e si allinea meglio al contratto onGetTimestamps(startDate, endDate) già usato altrove nel progetto.
Lo storico con TimesheetScreen
La schermata di archivio usa un periodo decisamente più ampio rispetto alla schermata NFC:
export default function TimesheetScreen() {
const { timestamps, loading, refreshing, onRefresh } = useTimestamps(65);
if (loading) {
return (
<View className="flex-1 justify-center items-center bg-white">
<ActivityIndicator size="large" color="#5a4ca7" />
<Text>Caricamento timbrature...</Text>
</View>
);
}
if (!timestamps || Object.keys(timestamps).length === 0) {
return <EmptyState message="Nessuna timbratura in archivio" />;
}
return (
<View className="flex-1 bg-gray-100 pt-3">
<ScrollView
className="flex-1 w-[90%] self-center"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
<TimestampsList data={timestamps} badge order="recentFirst" />
</ScrollView>
</View>
);
}
L’ordine recentFirst mette in evidenza i giorni più recenti, mentre badge={true} abilita le intestazioni mese/anno della lista, utili quando l’archivio copre più mesi consecutivi.
Per integrare il filtro temporale, la schermata può mantenere un range nello stato:
const [range, setRange] = useState({
start: initialStartDate,
end: initialEndDate,
});
const [filterVisible, setFilterVisible] = useState(false);
Dopo onChangeRange, la schermata deve aggiornare il range e richiamare il caricamento con le due date corrette. È preferibile evitare che sia il componente calendario stesso a conoscere API, token o formato della risposta — quella conoscenza appartiene a un livello più alto.
Il rendering con TimestampsList
Per ogni data, il componente ignora i gruppi vuoti o non validi, converte la chiave in una data locale, ordina gli eventi per orario, filtra gli eventi con status == 0, separa entrate e uscite, pareggia le colonne quando una coppia è incompleta, e infine renderizza il calendario del giorno con le relative tabelle.
La logica di pareggiamento è utile per non spostare visivamente gli orari sullo schermo:
const maxLength = Math.max(ingressi.length, uscite.length);
while (ingressi.length < maxLength) ingressi.push("-");
while (uscite.length < maxLength) uscite.push("-");
Il carattere - non significa necessariamente un errore: significa soltanto che per quella posizione non esiste un evento corrispondente. Il testo, o una legenda dedicata, possono aiutare gli utenti a interpretarlo correttamente senza fraintendimenti.
La lista distingue anche i giorni della settimana attraverso stili diversi per domenica, sabato e giorni lavorativi. Il colore, però, non dovrebbe mai essere l’unico segnale usato: la data resta sempre visibile in testo, per chi non percepisce bene le differenze cromatiche.
Object.entries(data).reverse() presuppone che l’ordine delle chiavi ricevute sia già coerente con l’ordine temporale atteso. Per dati critici è più robusto ordinare esplicitamente le chiavi dopo averle convertite in date:
const entries = Object.entries(data).sort(([dateA], [dateB]) =>
dateA.localeCompare(dateB)
);
La scelta dell’ordinamento va poi applicata in base al parametro order. In fase di revisione del progetto è utile verificare il formato reale restituito dal backend, e la gestione del fuso orario associata.
Selezionare un intervallo con PeriodFilterModal
PeriodFilterModal usa react-native-calendars, e mantiene un intervallo locale con start ed end
La selezione segue una regola piuttosto semplice: se non c’è ancora un inizio, il giorno selezionato diventa start; se esiste già un intervallo completo, una nuova selezione ne inizia uno nuovo; se il secondo giorno scelto è precedente al primo, le date vengono invertite automaticamente; altrimenti, il giorno selezionato diventa semplicemente end.
La parte essenziale della logica è questa:
const onDayPress = (day) => {
if (!range.start || range.end) {
setRange({ start: day.dateString, end: null });
return;
}
if (new Date(day.dateString) < new Date(range.start)) {
setRange({ start: day.dateString, end: range.start });
} else {
setRange({ ...range, end: day.dateString });
}
};
Il calendario evidenzia tutte le date comprese tra inizio e fine. Il pulsante Applica resta disabilitato finché il range non è completo:
const applySelection = () => {
if (range.start && range.end) {
onChangeRange(range);
onConfirm();
}
};
CalendarPresets può offrire intervalli frequenti come la settimana corrente, il mese corrente, o gli ultimi giorni. I preset riducono i tap necessari, e sono particolarmente utili su schermi piccoli. Ogni preset deve produrre lo stesso formato del calendario, per esempio:
setRange({
start: "2026-08-01",
end: "2026-08-10",
});
Non bisogna mai applicare un preset con un formato diverso, o con date locali ambigue: il componente chiamante deve ricevere sempre un contratto uniforme, senza eccezioni nascoste.
FilterModal e filtri estendibili
FilterModal è un componente generico per liste di opzioni selezionabili. Riceve options da mostrare, selected con gli identificativi attivi, onToggle per cambiare selezione, onConfirm per applicare, onCancel per annullare, e loading per quando le opzioni devono ancora essere caricate.
<FilterModal
visible={visible}
filterType="Tipo di timbratura"
options={options}
selected={selected}
onToggle={toggleOption}
onConfirm={applyFilters}
onCancel={cancelFilters}
onClose={closeModal}
loading={loadingOptions}
/>
Il componente supporta oggetti con type_id e type_name, oppure oggetti più generici con id e label:
const id = item.type_id || item.id;
const label = item.type_name || item.label;
Questa flessibilità è utile, ma va tenuta comunque sotto controllo. Se il contratto diventa stabile nel tempo, è preferibile normalizzare i dati prima di passarli al componente, così il rendering non deve più conoscere formati multipli.
Il filtro temporale è concettualmente diverso dal filtro per tipo: il primo produce una coppia di date, il secondo una collezione di identificativi. Possono condividere lo stile visivo del modale, ma non devono mai essere fusi in un’unica callback poco chiara.
Pull-to-refresh e stati della schermata
Il pull-to-refresh è utile perché lo storico può cambiare dopo una timbratura effettuata da un altro dispositivo, o dopo una correzione amministrativa avvenuta nel frattempo. React Native lo gestisce con RefreshControl:
<ScrollView
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
/>
}
>
<TimestampsList data={timestamps} badge order="recentFirst" />
</ScrollView>
È importante distinguere loading da refreshing, perché rispondono a esigenze diverse: loading riguarda il primo caricamento, e può mostrare una schermata dedicata a tutto schermo; refreshing riguarda invece un aggiornamento manuale, e deve lasciare visibile il contenuto già presente mentre aggiorna in background.
Il ciclo di refresh deve sempre riportare refreshing a false, anche se la richiesta fallisce lungo il percorso. Il blocco finally è il punto corretto per garantire questo comportamento senza eccezioni. Per una schermata completa conviene prevedere anche uno stato di errore con un’azione Riprova ben visibile: registrare l’errore soltanto in console lascia l’utente davanti a una lista vuota, senza alcuna spiegazione del perché.
Integrare onGetTimestamps con un contratto
Nel modello a package separati, la feature non dovrebbe importare direttamente il client HTTP dell’app. Riceve invece una dipendenza con un contratto esplicito, che ne definisce i confini.
Il contratto applicativo del progetto espone:
export const appNfcContracts = {
onGetTimestamps: async (startDate, endDate) => {
return apiClient.get("/get_timestamps", {
start_date: startDate,
end_date: endDate,
});
},
};
La feature può consumare la callback senza conoscere endpoint, autenticazione o libreria HTTP sottostante:
const loadTimestamps = useCallback(async () => {
setLoading(true);
try {
const data = await dependencies.onGetTimestamps(
range.start,
range.end
);
setTimestamps(data);
} finally {
setLoading(false);
}
}, [dependencies, range.start, range.end]);
Questo approccio porta con sé tre vantaggi concreti: il componente diventa più facile da testare con una callback finta; il package smette di dipendere dalla configurazione HTTP dell’app shell; e il formato della richiesta resta documentato, e può essere fatto evolvere in modo controllato nel tempo.
Il contratto deve chiarire anche il formato della risposta e degli errori. Se il backend restituisce sempre un oggetto raggruppato per data, la feature può validarlo prima del rendering:
const normalized = data && typeof data === "object" ? data : {};
setTimestamps(normalized);
La normalizzazione, però, non deve mai nascondere un errore di contratto reale. In caso di risposta inattesa, un log e uno stato di errore espliciti sono decisamente più utili di una lista che appare semplicemente vuota, senza motivazione.
Errori comuni
Inviare date localizzate è un errore facile da commettere senza accorgersene: formati come 10/08/2026 dipendono dalla locale del dispositivo, e possono essere interpretati in modo diverso a seconda del contesto. Meglio usare sempre YYYY-MM-DD nel contratto, senza eccezioni.
Mescolare UTC e ora locale crea disallineamenti sottili: la chiave della giornata e l’orario del singolo evento devono seguire una regola coerente tra loro. Vale la pena verificare timezone, cambio dell’ora legale, e conversione con Date in ogni punto del flusso.
Confondere lista vuota ed errore è un errore concettuale piuttosto comune: una risposta senza eventi è una condizione perfettamente valida, e deve mostrare uno stato vuoto dedicato. Un errore di rete, invece, deve offrire una spiegazione chiara e un nuovo tentativo.
Ricaricare a ogni render spreca risorse senza motivo: dipendenze instabili nell’hook possono creare richieste ripetute inutilmente. Usare useCallback con dipendenze precise resta la soluzione più semplice.
Applicare il filtro a metà selezione produce risultati incoerenti: un solo giorno selezionato non è sempre un intervallo completo. Meglio disabilitare Applica finché start ed end non sono entrambi valorizzati, oppure definire esplicitamente una semantica dedicata per il filtro giornaliero.
Affidarsi solo all’ordine degli oggetti è rischioso: le chiavi data vanno ordinate esplicitamente quando l’ordine conta davvero. Non bisogna mai assumere che ogni risposta API arrivi già nel formato grafico corretto.
Caricare troppo storico in una sola richiesta, infine, penalizza le prestazioni: intervalli molto ampi aumentano tempo di risposta e consumo di memoria. Vale la pena valutare paginazione, limiti massimi, e preset pratici per l’utente.
Domande frequenti
Quanti giorni deve mostrare l’archivio di default?
Dipende dal prodotto e dai limiti dell’API. Nel riferimento la schermata usa 65 giorni, mentre la schermata NFC carica solo gli ultimi 2. Il valore deve essere una scelta esplicita, non un numero duplicato distrattamente in più componenti.
Posso usare lo stesso hook per ultime timbrature e archivio?
Sì, se l’hook supporta un range configurabile. È comunque utile mantenere separati i due casi d’uso: la schermata NFC privilegia la rapidità, l’archivio privilegia ricerca e consultazione approfondita.
Perché usare un modale per il filtro?
Su mobile, il modale consente di concentrarsi sul calendario senza perdere il contesto della lista sottostante. Deve però avere annulla, applica e chiusura sempre coerenti tra loro.
Che cosa fa onGetTimestamps?
È una callback del contratto applicativo che riceve data iniziale e finale, e delega al client HTTP la richiesta allo storico. La feature non deve conoscere direttamente l’endpoint usato.
Come mostro un giorno senza uscita?
La lista può mantenere l’allineamento delle colonne usando un segnaposto come -. Se il dato ha valore amministrativo, si può aggiungere una legenda o uno stato più esplicito.
Il pull-to-refresh sostituisce l’aggiornamento dopo una timbratura?
No. Il refresh manuale è un controllo esplicito dell’utente. Dopo una timbratura riuscita, la schermata può ricaricare automaticamente il range breve, o aggiornare i dati in memoria, purché eviti richieste duplicate nel frattempo.
Conclusione
Un archivio timbrature ben progettato combina tre livelli distinti: un hook che gestisce caricamento e refresh, modali che trasformano una richiesta generica in un intervallo comprensibile, e una lista che rende leggibile una struttura raggruppata per giorno.
useTimestamps separa la logica asincrona dalla UI, PeriodFilterModal gestisce la selezione delle date, FilterModal offre una base riutilizzabile, e TimestampsList presenta ingressi e uscite in modo ordinato e prevedibile.
Il contratto onGetTimestamps(startDate, endDate) completa questa separazione: l’archivio decide che cosa chiedere, l’app shell decide come chiamare effettivamente il backend.
La regola pratica, in definitiva, è partire sempre dal periodo e dal contratto, non dal calendario in sé. Quando date, stati e risposta API sono definiti in modo chiaro fin dall’inizio, il componente visuale diventa più semplice, più testabile, e decisamente più affidabile nel tempo.




