Una fattura scade il 25 ottobre. L’applicazione riceve 2026-10-25, ma su un server configurato per New York mostra il 24. In UTC e a Roma il giorno è corretto.
È un buon caso da affidare a un agente: pochi file, un risultato sbagliato, un requisito comprensibile. Prima di chiedergli una correzione, però, vale la pena fermarsi su una decisione: quale osservazione distingue una diagnosi plausibile dalla causa che stiamo cercando? Cambiare subito il fuso dell’intero processo farebbe sparire il sintomo e potrebbe modificare altri comportamenti dell’applicazione.
Il laboratorio seguente riproduce questo problema in Docker. Arriveremo a una modifica piccola, con gli stessi test eseguiti prima e dopo, e a una seconda patch da scartare per un motivo preciso.
La scadenza e l’evento hanno significati diversi
Una data civile identifica un giorno del calendario, come una scadenza contrattuale o una data di nascita. Qui il valore 2026-10-25 significa “25 ottobre”, senza un’ora e senza una conversione richiesta fra fusi.
Un istante identifica invece un momento sulla linea del tempo. 2026-10-25T00:30:00Z è mezzanotte e mezza UTC: a New York appartiene ancora al 24 ottobre. Per un evento è il risultato corretto.
Questa distinzione decide che cosa dovrà fare la patch. La scadenza deve conservare il giorno; la visualizzazione degli eventi deve continuare a usare il fuso dell’applicazione.
Preparare il caso minimo
Servono Docker Engine o Docker Desktop avviato, un terminale Bash o zsh e un editor. Node verrà eseguito nel container: non occorre installarlo sul computer né aggiungere pacchetti npm. Puoi seguire tutto manualmente; se usi già Claude Code o Codex, apri la sessione nella stessa cartella.
Crea una cartella vuota ed entraci:
mkdir date-debug-lab
cd date-debug-labIl percorso completo comprende tre file che scriverai tu. Creiamo prima implementazione e sonda; il file dei test arriva dopo la diagnosi.
date-debug-lab/
date.mjs
probe.mjs
date.test.mjsIn date.mjs inserisci questa versione iniziale:
const pad = value => String(value).padStart(2, '0');
function requireCivilDate(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new RangeError('Expected a civil date in YYYY-MM-DD format');
}
const parsed = new Date(`${value}T00:00:00.000Z`);
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
throw new RangeError('Invalid calendar date');
}
}
export function formatDueDate(value) {
requireCivilDate(value);
const date = new Date(value);
return `${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()}`;
}
export function formatInstant(value, timeZone) {
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit', month: '2-digit', year: 'numeric', timeZone,
}).format(new Date(value));
}La validazione rifiuta formati diversi da YYYY-MM-DD e date che non esistono. Il controllo di ritorno tramite toISOString() serve anche a intercettare date che JavaScript normalizza in un altro giorno. Il difetto da isolare è nella formattazione della scadenza, che legge le componenti locali di un oggetto Date.
Scrivi ora probe.mjs. Una sonda è un programma breve che rende visibili i valori utili alla diagnosi:
import { formatDueDate, formatInstant } from './date.mjs';
const input = '2026-10-25';
const parsed = new Date(input);
console.log(JSON.stringify({
requestedZone: process.env.TZ,
resolvedZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
input,
epochMs: parsed.getTime(),
iso: parsed.toISOString(),
local: { year: parsed.getFullYear(), month: parsed.getMonth() + 1, day: parsed.getDate() },
dueDate: formatDueDate(input),
instant: formatInstant('2026-10-25T00:30:00.000Z'),
}, null, 2));Scarica l’immagine fissata per il laboratorio:
LAB_IMAGE='node@sha256:3d27e5c11e5786e309ec3e03f93ae536eb36e6e5eb3714d5eb3300a36157add0'
docker pull "$LAB_IMAGE"Le esecuzioni riportate usano Node 24.21.0 su Linux arm64, con ICU 78.3 e dati dei fusi 2026c. Il digest fissa l’immagine; su un’architettura diversa Docker può selezionare una variante di piattaforma diversa.
Definisci questa funzione nel terminale, restando in date-debug-lab:
run_case() {
LAB_TZ="$1"
LAB_EXPECTED="$2"
shift 2
docker run --rm --pull never --network none --read-only \
--cap-drop ALL --security-opt no-new-privileges \
--memory 128m --cpus 1 --pids-limit 64 --user node \
--env "TZ=$LAB_TZ" --env "EXPECTED_INSTANT_DATE=$LAB_EXPECTED" \
--mount "type=bind,src=$PWD,dst=/app,readonly" \
--workdir /app "$LAB_IMAGE" node "$@"
}run_case riceve il fuso, la data attesa per l’evento e poi il comando Node. Il secondo argomento servirà ai test; la sonda non lo usa. Il codice è montato in sola lettura, la rete è disabilitata e il container viene eliminato alla fine. --pull never fa fallire il comando se l’immagine non è già disponibile.
Il pacchetto completo conserva le tre versioni, i test, il README e i risultati, se preferisci confrontare i file già separati:
Due ipotesi, una sola variabile
“È un problema di timezone” è ancora una descrizione troppo larga per scegliere dove intervenire. Possiamo formulare due spiegazioni che prevedono risultati diversi.
| Ipotesi | Che cosa dovrebbe cambiare passando da UTC a New York |
|---|---|
| Il parsing costruisce un istante diverso | Il numero restituito da getTime() e la rappresentazione UTC di toISOString(). |
| L’istante è uguale; cambia la lettura locale | Il giorno letto da getDate(), mentre epoch e rappresentazione UTC restano uguali. |
L’epoch è il numero di millisecondi dall’inizio del 1970 in UTC; ci consente di confrontare gli istanti senza dipendere da come vengono mostrati. toISOString() rende leggibile lo stesso confronto in UTC.
Qui l’agente può proporre le ipotesi e preparare l’esperimento. Questo prompt, in inglese per poterlo riutilizzare nei due strumenti, gli chiede di fermarsi prima della modifica:
Investigate this failure before changing the implementation.
Domain contract:
- An invoice due date is a civil date in canonical YYYY-MM-DD form.
- Display it as DD/MM/YYYY without changing the calendar day.
- Event timestamps are instants. Their display must still use the application's timezone.
Observed symptom:
2026-10-25 displays as 25/10/2026 in UTC and as 24/10/2026 in America/New_York.
Read date.mjs and probe.mjs. Do not edit files yet.
1. State two competing explanations and the observation each predicts.
2. Propose a command that distinguishes them while keeping the input, code and runtime fixed.
3. If tool access is available, run the experiment and include the actual output. Otherwise, give me the command and wait for its output.
4. Identify which explanation the observation rules out and what remains unresolved.
5. Propose a regression test that fails before the fix and a test protecting valid instant conversion.
Do not propose a patch until the observation supports the diagnosis.
Keep expected output separate from output actually observed.Se l’agente non ha accesso al terminale, esegui il comando proposto e restituiscigli l’output. Se lo ha, controlla che abbia eseguito proprio il confronto richiesto. La documentazione di Claude Code sul debugging insiste già su comando di riproduzione, passaggi e frequenza del difetto. Qui aggiungiamo una previsione che l’osservazione possa smentire, seguendo il metodo descritto in The Debugging Book.
Esegui la sonda tre volte:
run_case UTC 25/10/2026 probe.mjs
run_case Europe/Rome 25/10/2026 probe.mjs
run_case America/New_York 24/10/2026 probe.mjsInput, sorgenti e runtime restano gli stessi. Cambia TZ, la variabile con cui Node configura il fuso.
Le tre esecuzioni hanno restituito:
{
"epochMs": 1792886400000,
"iso": "2026-10-25T00:00:00.000Z"
}Le componenti locali e la scadenza mostrata, invece, cambiano:
| Fuso | Giorno locale | Scadenza mostrata |
|---|---|---|
| UTC | 25 | 25/10/2026 |
| Europe/Rome | 25 | 25/10/2026 |
| America/New_York | 24 | 24/10/2026 |
La prima ipotesi è smentita da questo confronto: l’istante costruito è identico. Il passaggio che introduce la differenza è la lettura delle componenti locali. La specifica ECMAScript spiega perché: una stringa date-only senza offset viene interpretata in UTC. Leggerne poi il giorno locale a New York porta al giorno precedente.
Scrivere il test prima di correggere
Ora crea date.test.mjs. Il primo test traduce il requisito della fattura; gli altri coprono altre date, input non validi e il comportamento degli eventi che vogliamo conservare.
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatDueDate, formatInstant } from './date.mjs';
test('civil due date keeps its calendar day', () => {
assert.equal(formatDueDate('2026-10-25'), '25/10/2026');
});
test('civil date on the spring clock change keeps its calendar day', () => {
assert.equal(formatDueDate('2026-03-29'), '29/03/2026');
});
test('valid leap day is accepted', () => {
assert.equal(formatDueDate('2024-02-29'), '29/02/2024');
});
test('non-leap February 29 is rejected', () => {
assert.throws(() => formatDueDate('2026-02-29'), RangeError);
});
test('February 30 is rejected', () => {
assert.throws(() => formatDueDate('2026-02-30'), RangeError);
});
test('month 13 is rejected', () => {
assert.throws(() => formatDueDate('2026-13-01'), RangeError);
});
test('timestamp is rejected by the civil-date function', () => {
assert.throws(() => formatDueDate('2026-10-25T00:30:00Z'), RangeError);
});
test('noncanonical date is rejected', () => {
assert.throws(() => formatDueDate('2026-1-5'), RangeError);
});
test('a real instant has October 25 in UTC', () => {
assert.equal(formatInstant('2026-10-25T00:30:00Z', 'UTC'), '25/10/2026');
});
test('the same real instant has October 24 in New York', () => {
assert.equal(formatInstant('2026-10-25T00:30:00Z', 'America/New_York'), '24/10/2026');
});
test('the default instant display keeps the configured application timezone', () => {
assert.ok(process.env.EXPECTED_INSTANT_DATE, 'Set EXPECTED_INSTANT_DATE for this run');
assert.equal(formatInstant('2026-10-25T00:30:00Z'), process.env.EXPECTED_INSTANT_DATE);
});Esegui la stessa suite nei tre fusi:
run_case UTC 25/10/2026 --test date.test.mjs
run_case Europe/Rome 25/10/2026 --test date.test.mjs
run_case America/New_York 24/10/2026 --test date.test.mjsLa versione iniziale passa tutti gli 11 test in UTC e a Roma. A New York ne passa 8 e ne fallisce 3: scadenza del 25 ottobre, data del 29 marzo e giorno bisestile. L’ultimo comando termina con codice 1. È il fallimento che cercavamo di riprodurre, non un problema nell’avvio del laboratorio.
Il 29 marzo e il 25 ottobre sono anche giorni del cambio d’ora a Roma nel 2026. Il difetto compare però pure sul 29 febbraio: attribuirlo soltanto all’ora legale porterebbe fuori strada.
Correggere il valore giusto
Sostituisci soltanto formatDueDate in date.mjs:
export function formatDueDate(value) {
requireCivilDate(value);
const [year, month, day] = value.split('-');
return `${day}/${month}/${year}`;
}Elimina anche la costante pad nella prima riga, ora inutilizzata. Lascia invariati requireCivilDate, formatInstant e tutti i test.
La validazione continua a controllare che il giorno esista. Per presentarlo, usiamo direttamente anno, mese e giorno della data civile. L’oggetto Date resta confinato al controllo del calendario; la visualizzazione della scadenza non passa più dalle componenti locali.
Ripeti i tre comandi della suite. La versione corretta ha passato 11 test su 11 in ciascun fuso, compresi i due risultati diversi per lo stesso istante: 25 ottobre in UTC e 24 a New York.
Cambiare globalmente il fuso in UTC avrebbe aggirato il primo errore. Il pacchetto contiene anche questa controprova: la versione iniziale con process.env.TZ = 'UTC' aggiunto in testa al modulo. A New York passa 10 test su 11; fallisce quello che conserva la visualizzazione degli eventi nel fuso configurato. Quell’evento ora appare il 25 invece del 24.
Scarterei quindi quella patch anche se il test della fattura fosse verde. Corregge il sintomo spostando un’altra parte del requisito. Un’applicazione progettata per mostrare tutti gli eventi in UTC farebbe una scelta diversa; qui abbiamo deciso di mantenere il fuso applicativo.
Che cosa riportare nel progetto reale
Il caso ridotto ci lascia un’indicazione precisa: cercare il punto in cui una data civile viene trasformata in un istante e poi letta nel fuso locale. Nel progetto reale bisogna ricostruire lo stesso passaggio, dal valore ricevuto fino al componente che lo presenta.
Se il programma ridotto smette di riprodurre l’errore, recupera l’ultimo elemento rimosso che lo faceva comparire. Una libreria di parsing, un dato esterno o l’ordine di due operazioni possono essere proprio la parte determinante. La riduzione serve a conservare il comportamento sbagliato eliminando il resto, come spiega il capitolo di The Debugging Book sugli input che causano un errore.
Alla diagnosi bastano poche righe utili anche alla review: requisito della data civile, comando che fallisceva, osservazione che ha escluso il parsing diverso, test rimasti invariati e comportamento degli istanti conservato. Da qui l’agente può preparare la patch nel progetto, con un confine molto più chiaro. Per decidere se accettare il diff, il passaggio successivo è la code review della proposta e dei suoi test.
Per chiudere il laboratorio non serve un docker prune: i container sono già stati rimossi da --rm, senza volumi né pacchetti installati. Conserva i tre file finché ti servono, poi elimina soltanto la cartella che hai creato. L’immagine può restare se la usi anche altrove.

