Acquisizione di dati indipendenti dalla posizione e date parziali con Temporal

Funzionalità web: Temporal

La registrazione di dati cronologici che dovrebbero rimanere identici indipendentemente dalla posizione del visualizzatore, come date di nascita, sveglie giornaliere, scadenze delle carte di credito o cicli di fatturazione mensili, è storicamente soggetta a errori con l'oggetto Date precedente. Poiché un Date rappresenta sempre un istante specifico nel tempo e viene visualizzato utilizzando il fuso orario locale del visualizzatore, il salvataggio di una data come "1990-01-01" può far sì che gli utenti a ovest del fuso orario UTC vedano "1989-12-31", mentre la modellazione di date parziali (come la scadenza di una carta solo per mese) forza giorni segnaposto arbitrari che causano bug di overflow.

L'API Temporal fornisce una famiglia di tipi "Plain" indipendenti dalla località che non contengono un offset del fuso orario e rappresentano le date del calendario e gli orari dell'orologio esattamente come vengono letti da un calendario o da un orologio:

Tipo Cosa modella Casi d'uso tipici
Temporal.PlainDate Data di calendario completa (year, month, day) Date di nascita, date di eventi, date di fatturazione degli abbonamenti
Temporal.PlainTime Ora dell'orologio (hour, minute, second…) Sveglie giornaliere (alle 09:00 ovunque si trovi l'utente), orario di apertura
Temporal.PlainDateTime Data di calendario + ora dell'orologio senza fuso orario Pianificazioni locali mobili, che combinano un PlainDate e un PlainTime prima di collegare una zona
Temporal.PlainMonthDay Mese e giorno senza anno Compleanni, anniversari e festività fisse annuali ricorrenti
Temporal.PlainYearMonth Anno e mese senza giorno Scadenze delle carte di credito (2027-12), periodi di fatturazione mensili

Modalità di implementazione

  1. Scegli il tipo di Temporal.Plain* più specifico che corrisponde ai tuoi dati:
    • Utilizza Temporal.PlainDate.from() per le date di calendario complete e Temporal.PlainTime.from() per gli orari.
    • Utilizza Temporal.PlainDateTime.from() (o plainDate.toPlainDateTime(plainTime)) quando combini una data di calendario e un'ora senza vincolarle a un fuso orario.
    • Utilizza Temporal.PlainMonthDay.from() per le date annuali senza anno e converti in PlainDate con .toPlainDate({ year }) prima di eseguire l'aritmetica delle date.
    • Utilizza Temporal.PlainYearMonth.from() per i valori di anno e mese, in modo che i numeri dei giorni arbitrari non vengano mai inseriti nei calcoli.
  2. Nota il parametro calendar quando crei tipi Plain: ISO 8601 ('iso8601') è il calendario predefinito quando analizzi stringhe ISO o crei con PlainDate.from({ year, month, day }). Quando crei PlainMonthDay o PlainYearMonth da property bag o quando utilizzi calendari non ISO, passa calendar in modo esplicito (ad es. { month: 10, day: 31, calendar: 'iso8601' }).
  3. Utilizza .add() con le opzioni overflow per gli intervalli di calendario ricorrenti: la chiamata a plainDate.add({ months: 1 }) utilizza per impostazione predefinita { overflow: 'constrain' }, bloccando le date di fine mese come il 31 gennaio al 28/29 febbraio. Passa { overflow: 'reject' } quando le date target non valide devono generare un RangeError anziché un blocco.

Esempio: date complete, ore e PlainDateTime

// 1. Parse a calendar date and a wall-clock time from input strings
const birthdate = Temporal.PlainDate.from('1990-01-01');
const reminderTime = Temporal.PlainTime.from('09:00');

// Combine into a location-agnostic PlainDateTime without string interpolation
const localAppointment = birthdate.toPlainDateTime(reminderTime);

// 2. Display without time zone conversion; outputs the same date in any time zone
console.log(birthdate.toLocaleString('en-GB'));
console.log(localAppointment.toString()); // "1990-01-01T09:00:00"

// 3. Read calendar fields directly from the Plain type. A PlainDate already *is*
// a calendar date, so there is no need for the formatToParts() workaround that
// legacy Date code uses to recover year/month/day from an instant.
const { year, month, day } = birthdate;

Esempio: date parziali (PlainMonthDay e PlainYearMonth)

// Annual recurring event (no year component)
// Include explicit calendar when constructing from an object
const annualHoliday = Temporal.PlainMonthDay.from({
  month: 10,
  day: 31,
  calendar: 'iso8601',
});

const today = Temporal.Now.plainDateISO();
const isToday = annualHoliday.equals(today.toPlainMonthDay());

// Convert PlainMonthDay to a PlainDate with a specific year before doing arithmetic,
// because month lengths (such as February) depend on whether the year is a leap year.
const occurrenceThisYear = annualHoliday.toPlainDate({ year: today.year });

// Monthly expiration (no day component, preventing day-of-month overflow bugs)
const cardExpiry = Temporal.PlainYearMonth.from({
  year: 2027,
  month: 12,
  calendar: 'iso8601',
});
const currentMonth = today.toPlainYearMonth();
const isExpired = Temporal.PlainYearMonth.compare(cardExpiry, currentMonth) < 0;

Esempio: intervalli mensili ricorrenti (overflow: 'constrain' rispetto a 'reject')

const billingStart = Temporal.PlainDate.from('2024-01-31');

// Default overflow: 'constrain' clamps Jan 31 + 1 month to the last valid day of February
const nextBilling = billingStart.add({ months: 1 }, { overflow: 'constrain' });
console.log(nextBilling.toString()); // "2024-02-29" (2024 is a leap year)

// Use overflow: 'reject' to throw a RangeError when the target day does not exist
try {
  billingStart.add({ months: 1 }, { overflow: 'reject' });
} catch (err) {
  // RangeError: day 31 does not exist in February
}

Esempio: interoperabilità con la rete legacy Date

Un valore Date legacy è un timestamp (un istante), non una data di calendario. La memorizzazione di "1990-01-01" come Date ti costringe a scegliere un istante (in genere la mezzanotte UTC) e la lettura in un fuso orario a ovest di UTC restituisce "31/12/1989". Se una libreria o un'API richiede ancora un Date, esegui la conversione esplicita tramite Temporal.Instant anziché creare manualmente stringhe ISO:

// PlainDate -> Date: attach a time zone (time of day defaults to the start of
// that day) to get an Instant, then hand the epoch milliseconds to Date.
// 'UTC' is a deliberate choice and must be documented; any zone would do,
// but the stored instant depends on it.
const instant = birthdate.toZonedDateTime('UTC').toInstant();
const legacyDate = new Date(instant.epochMilliseconds);

// Date -> PlainDate: go back through Instant (legacyDate.toTemporalInstant()
// is equivalent), then project into an explicit time zone. The result depends
// on the zone, which is exactly the drift risk.
const viewedDate = Temporal.Instant.fromEpochMilliseconds(legacyDate.getTime())
  .toZonedDateTimeISO('America/New_York')
  .toPlainDate();

// Compare Plain types with .equals() (or Temporal.PlainDate.compare), not strings.
console.log(viewedDate.equals(birthdate)); // false: the calendar date drifted to 1989-12-31

Implementazione strategica e best practice

  • DO use Temporal.PlainDate for full calendar dates, Temporal.PlainTime for wall-clock times, Temporal.PlainDateTime for local date-time pairs without a zone, Temporal.PlainMonthDay for recurring annual dates, and Temporal.PlainYearMonth for year-month expirations or billing periods.
  • NON eseguire operazioni aritmetiche direttamente su Temporal.PlainMonthDay; convertilo prima in Temporal.PlainDate tramite .toPlainDate({ year }) perché la durata dei mesi varia in base all'anno.
  • USA .add() su Temporal.PlainDate per intervalli di calendario ricorrenti, basandoti su overflow: 'constrain' (impostazione predefinita) per bloccare le date di fine mese o su overflow: 'reject' per generare un RangeError per le date inesistenti.
  • SÌ: specifica calendar (ad es. calendar: 'iso8601') quando crei tipi PlainMonthDay o Plain non ISO da oggetti.
  • NON utilizzare i tipi Plain se devi rappresentare un momento specifico nel tempo fisico (un istante). Utilizza Temporal.Instant o Temporal.ZonedDateTime per i log, i timestamp degli eventi o la pianificazione tra fusi orari.
  • NON costruire oggetti Date da stringhe ISO create manualmente (come `${date}T00:00:00Z`). Genera un Date da epochMilliseconds di un Temporal.Instant o Temporal.ZonedDateTime e leggine uno con Temporal.Instant.fromEpochMilliseconds(date.getTime()) o date.toTemporalInstant(), in modo che l'ipotesi del fuso orario sia esplicita.
  • NON utilizzare Intl.DateTimeFormat.prototype.formatToParts() per estrarre o confrontare i valori di anno, mese o giorno. Leggi .year, .month e .day direttamente da un tipo Plain e confrontali con .equals().
  • SÌ: identifica i fusi orari in base al nome IANA (ad es. America/New_York) e mai in base a un offset UTC fisso. Gli offset cambiano con l'ora legale e la legislazione, quindi etichette come "UTC-5" diventano errate per una parte dell'anno.
  • RICORDA che gli oggetti Temporal sono immutabili. Metodi come .add(), .subtract() o .with() restituiscono sempre una nuova istanza con un nuovo riferimento anziché modificare l'oggetto originale, il che li rende sicuri da archiviare direttamente nello stato della UI reattiva.

Strategia di riserva

Fallback e supporto del browser per Temporal

Per i browser che non supportano ancora l'API Temporal nativa, utilizza il rilevamento delle funzionalità e carica dinamicamente temporal-polyfill (~15 kB min+gz).

Tieni presente che l'importazione di temporal-polyfill non assegna automaticamente l'oggetto Temporal all'ambito globale. Assegnalo esplicitamente se il tuo codice si basa su globalThis.Temporal.

// Check if Temporal is supported natively
(async () => {
  if (typeof Temporal === 'undefined') {
    // Load the polyfill conditionally
    const module = await import('https://esm.sh/temporal-polyfill');
    globalThis.Temporal = module.Temporal;
    // Extend Date.prototype if needed
    Date.prototype.toTemporalInstant = module.toTemporalInstant;
  }
  initializeApp();
})();