Módulo 05: Fechas y horas con java.time

Objetivos del módulo

  • Entender por qué java.util.Date y Calendar dieron tantos problemas y por qué se sustituyeron
  • Conocer el mapa de clases de java.time y elegir la clase adecuada para cada situación
  • Trabajar con LocalDate, LocalTime, LocalDateTime, Instant, ZonedDateTime y OffsetDateTime
  • Manejar zonas horarias y el horario de verano sin sorpresas
  • Calcular con Duration, Period y ChronoUnit: edades, días entre fechas, días hábiles
  • Usar TemporalAdjusters para obtener “el primer lunes del mes” o “el último día del año”
  • Formatear y parsear fechas con DateTimeFormatter, en español y en modo estricto
  • Convertir entre java.time y las clases heredadas (Date, Calendar, java.sql.Timestamp)
  • Escribir código fácil de probar inyectando un Clock
  • Aplicar internacionalización básica: Locale, NumberFormat (moneda y porcentajes) y ResourceBundle

1. El problema: Date y Calendar

Antes de Java 8, la biblioteca estándar ofrecía java.util.Date (Java 1.0) y java.util.Calendar (Java 1.1). Ambas son famosas por sus trampas, y todavía las encontrarás en código antiguo, en bibliotecas viejas y en JDBC. Merece la pena verlas fallar para entender el diseño de la API moderna.

💻 Ejemplo: cinco problemas clásicos en un solo programa.

import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;

public class ProblemasDate {
    public static void main(String[] args) throws Exception {
        // 1. Los meses empiezan en 0 y el año se cuenta desde 1900
        @SuppressWarnings("deprecation")
        Date d = new Date(2026, 1, 30);           // ¿30 de febrero de 2026?
        System.out.println("new Date(2026, 1, 30) -> " + d);

        // 2. Calendar: meses desde 0 y fechas imposibles que "se corrigen" solas
        Calendar cal = new GregorianCalendar(2026, 1, 30); // febrero (1) día 30
        SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy");
        System.out.println("GregorianCalendar(2026, 1, 30) -> " + sdf.format(cal.getTime()));

        // 3. Date es mutable: cualquiera que tenga la referencia la puede cambiar
        Date inicio = sdf.parse("01/10/2026");
        Reserva r = new Reserva(inicio);
        inicio.setTime(0);                          // modificamos "nuestra" fecha...
        System.out.println("Fecha de la reserva -> " + sdf.format(r.fecha())); // ...y cambia la reserva

        // 4. SimpleDateFormat es indulgente (lenient) por defecto
        System.out.println("parse(\"45/13/2026\") -> " + sdf.format(sdf.parse("45/13/2026")));

        // 5. Date no es una fecha: es un instante con hora, y toString usa la zona del sistema
        System.out.println("new Date(0) -> " + new Date(0));
    }

    record Reserva(Date fecha) { }
}

Salida real (con la zona Europe/Madrid):

new Date(2026, 1, 30) -> Tue Mar 02 00:00:00 CET 3926
GregorianCalendar(2026, 1, 30) -> 02/03/2026
Fecha de la reserva -> 01/01/1970
parse("45/13/2026") -> 14/02/2027
new Date(0) -> Thu Jan 01 01:00:00 CET 1970

Analicemos cada línea:

Problema Qué ha pasado Consecuencia
Año desde 1900 y meses desde 0 new Date(2026, 1, 30) es el año 1900 + 2026 = 3926, mes 1 = febrero Errores de “uno menos” constantes
Fechas imposibles aceptadas El 30 de febrero se convierte en silencio en el 2 de marzo Datos corruptos sin ningún aviso
Mutabilidad La reserva guardaba la misma referencia; al cambiar inicio, cambia la reserva Hay que hacer copias defensivas en todas partes
Parseo indulgente 45/13/2026 se convierte en el 14/02/2027 Validar entradas es casi imposible
Nombre engañoso Date representa un instante (milisegundos desde 1970), no una fecha toString() depende de la zona del sistema

Además, SimpleDateFormat no es seguro entre hilos (thread-safe): si compartes una instancia en un servidor web, varios hilos a la vez producen fechas corruptas o excepciones aleatorias (lo entenderás en el módulo 06).

📘 Concepto: En Java 8 (2014) llegó java.time (JSR 310), diseñada por Stephen Colebourne a partir de su biblioteca Joda-Time. Sus principios son: inmutabilidad (cada operación devuelve un objeto nuevo), claridad (una clase distinta para cada concepto), meses de 1 a 12, validación estricta y seguridad entre hilos.

⚠️ Importante: En código nuevo no uses Date, Calendar ni SimpleDateFormat. Solo los necesitarás para comunicarte con APIs antiguas, y para eso existen métodos de conversión (sección 11).


2. El mapa de java.time

La clave de java.time es que cada concepto tiene su clase. Antes de escribir código, pregúntate: ¿necesito una fecha de calendario, una hora, un momento exacto en el planeta o una cantidad de tiempo?

Mapa de las clases principales de java.time: clases locales, momentos exactos, cantidades y utilidades

Figura 1. Mapa de las clases principales de java.time.

Clase Representa Ejemplo de valor Úsala para
LocalDate Fecha sin hora ni zona 2026-10-04 Cumpleaños, festivos, fecha de una factura
LocalTime Hora sin fecha ni zona 18:30 Horario de apertura, hora de una alarma diaria
LocalDateTime Fecha y hora sin zona 2026-10-04T18:30 Cita en una consulta local, hora de un evento “en la ciudad”
ZonedDateTime Fecha, hora y zona con reglas 2026-10-04T18:30+02:00[Europe/Madrid] Mostrar horas a usuarios de distintos países, calcular con horario de verano
OffsetDateTime Fecha, hora y desfase fijo 2026-10-04T18:30+02:00 Guardar en bases de datos, APIs REST, JSON
Instant Punto exacto en la línea de tiempo (UTC) 2026-10-04T16:30:00Z Marcas de tiempo, logs, medir tiempos
Duration Cantidad de tiempo en segundos y nanos PT2H22M Tiempos de espera, duración de una tarea
Period Cantidad en años, meses y días P1Y6M Edad, duración de un contrato

Todas viven en el paquete java.time (las utilidades, en java.time.format, java.time.temporal y java.time.zone) y comparten el mismo estilo de métodos:

Prefijo Significado Ejemplo
of Crear a partir de sus partes LocalDate.of(2026, 10, 4)
now Valor actual según un reloj LocalTime.now()
parse Crear a partir de texto LocalDate.parse("2026-10-04")
get Consultar una parte fecha.getMonthValue()
is Consultar una condición fecha.isLeapYear(), a.isBefore(b)
with Copia con una parte cambiada fecha.withDayOfMonth(1)
plus / minus Copia sumando o restando fecha.plusWeeks(2)
to Convertir a otro tipo ldt.toLocalDate()
at Combinar con otro dato fecha.atTime(10, 30), ldt.atZone(zona)
format Convertir a texto fecha.format(formateador)

💡 Consejo: Una buena regla práctica para aplicaciones reales: guarda y transmite instantes (Instant u OffsetDateTime en UTC) y convierte a la zona del usuario solo al mostrar. Usa LocalDate cuando la hora no importe.


3. LocalDate: fechas de calendario

📘 Concepto: LocalDate es una fecha del calendario ISO (gregoriano) sin hora ni zona: “el 25 de diciembre de 2026”. Es lo que escribirías en una agenda de papel.

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.Month;
import java.time.Year;
import java.time.YearMonth;
import java.time.MonthDay;

public class EjemploLocalDate {
    public static void main(String[] args) {
        // Formas de crear un LocalDate
        LocalDate hoy = LocalDate.now();                       // fecha actual del sistema
        LocalDate examen = LocalDate.of(2026, 6, 15);          // los meses van de 1 a 12
        LocalDate navidad = LocalDate.of(2026, Month.DECEMBER, 25);
        LocalDate desdeTexto = LocalDate.parse("2026-02-28");  // formato ISO-8601 (aaaa-mm-dd)
        LocalDate dia100 = LocalDate.ofYearDay(2026, 100);

        System.out.println("Hoy: " + hoy);
        System.out.println("Examen: " + examen);
        System.out.println("Navidad: " + navidad);
        System.out.println("Parseada: " + desdeTexto);
        System.out.println("Día 100 de 2026: " + dia100);

        // Consultar sus partes
        System.out.println("Año: " + navidad.getYear()
                + ", mes: " + navidad.getMonth() + " (" + navidad.getMonthValue() + ")"
                + ", día: " + navidad.getDayOfMonth());
        System.out.println("Día de la semana: " + navidad.getDayOfWeek());
        System.out.println("Día del año: " + navidad.getDayOfYear());
        System.out.println("¿2028 es bisiesto? " + LocalDate.of(2028, 1, 1).isLeapYear());
        System.out.println("Días de febrero de 2026: " + desdeTexto.lengthOfMonth());

        // Operaciones: devuelven SIEMPRE un objeto nuevo (inmutabilidad)
        LocalDate fin = examen.plusDays(20).plusMonths(1).minusYears(1);
        System.out.println("examen + 20 días + 1 mes - 1 año = " + fin);
        System.out.println("examen sigue valiendo " + examen);
        System.out.println("31 ene + 1 mes = " + LocalDate.of(2026, 1, 31).plusMonths(1));

        // Modificar un campo con withXxx
        System.out.println("Primer día del mes del examen: " + examen.withDayOfMonth(1));
        System.out.println("Mismo día en 2030: " + examen.withYear(2030));

        // Comparar
        System.out.println("¿examen antes de navidad? " + examen.isBefore(navidad));
        System.out.println("¿equals? " + examen.equals(LocalDate.of(2026, 6, 15)));
        System.out.println("compareTo: " + examen.compareTo(navidad));
        System.out.println("¿Es fin de semana? " + esFinDeSemana(navidad));

        // Fecha imposible: excepción inmediata, nada de "corregir" en silencio
        try {
            LocalDate.of(2026, 2, 30);
        } catch (java.time.DateTimeException e) {
            System.out.println("Error: " + e.getMessage());
        }

        // Clases "parciales"
        YearMonth caducidad = YearMonth.of(2027, 3);
        MonthDay cumple = MonthDay.of(Month.JULY, 14);
        System.out.println("Tarjeta caduca: " + caducidad + " (último día " + caducidad.atEndOfMonth() + ")");
        System.out.println("Cumpleaños: " + cumple + " -> en 2026 cae en " + cumple.atYear(2026).getDayOfWeek());
        System.out.println("Días de 2026: " + Year.of(2026).length());

        // Recorrer un rango de fechas con datesUntil (Java 9)
        LocalDate.of(2026, 10, 1).datesUntil(LocalDate.of(2026, 10, 6))
                .forEach(f -> System.out.print(f.getDayOfMonth() + "-" + f.getDayOfWeek() + " "));
        System.out.println();
    }

    static boolean esFinDeSemana(LocalDate f) {
        DayOfWeek d = f.getDayOfWeek();
        return d == DayOfWeek.SATURDAY || d == DayOfWeek.SUNDAY;
    }
}

Lo ejecutamos con el JDK 25 en Docker (lanzamiento directo de código fuente, sin compilar aparte):

docker run --rm -e TZ=Europe/Madrid -v "$PWD:/w" -w /w maven:3.9-eclipse-temurin-25 java EjemploLocalDate.java
Hoy: 2026-10-04
Examen: 2026-06-15
Navidad: 2026-12-25
Parseada: 2026-02-28
Día 100 de 2026: 2026-04-10
Año: 2026, mes: DECEMBER (12), día: 25
Día de la semana: FRIDAY
Día del año: 359
¿2028 es bisiesto? true
Días de febrero de 2026: 28
examen + 20 días + 1 mes - 1 año = 2025-08-05
examen sigue valiendo 2026-06-15
31 ene + 1 mes = 2026-02-28
Primer día del mes del examen: 2026-06-01
Mismo día en 2030: 2030-06-15
¿examen antes de navidad? true
¿equals? true
compareTo: -6
¿Es fin de semana? false
Error: Invalid date 'FEBRUARY 30'
Tarjeta caduca: 2027-03 (último día 2027-03-31)
Cumpleaños: --07-14 -> en 2026 cae en TUESDAY
Días de 2026: 365
1-THURSDAY 2-FRIDAY 3-SATURDAY 4-SUNDAY 5-MONDAY 

La primera línea depende del día en que lo ejecutes. Observa varios detalles importantes:

  • Inmutabilidad: examen.plusDays(20)... no modifica examen; devuelve otra fecha. Si escribes examen.plusDays(1); sin asignar el resultado, no ocurre nada (es un error típico).
  • Ajuste a fin de mes: el 31 de enero + 1 mes es el 28 de febrero, el último día válido. No “se desborda” a marzo como en Calendar.
  • Fechas imposibles: LocalDate.of(2026, 2, 30) lanza DateTimeException en vez de corregirla.
  • getMonth() devuelve el enumerado Month y getDayOfWeek() el enumerado DayOfWeek; su toString() está en inglés (en la sección 10 verás cómo obtener los nombres en español).
  • compareTo solo garantiza el signo (negativo, cero o positivo); no lo uses como “diferencia en días”.

💡 Consejo: Para comparar fechas usa isBefore, isAfter e isEqual (o equals), que se leen mucho mejor que compareTo(...) < 0. Y recuerda que los enumerados se comparan con ==.

Clases parciales

Clase Representa Ejemplo
Year Un año Year.of(2028).isLeap()
YearMonth Año y mes Caducidad de una tarjeta: YearMonth.of(2027, 3)
MonthDay Mes y día, sin año Cumpleaños o festivos fijos: MonthDay.of(12, 25)
Month Enumerado de los 12 meses Month.DECEMBER.getValue() → 12
DayOfWeek Enumerado de los 7 días (lunes = 1) DayOfWeek.MONDAY.plus(3) → THURSDAY

4. LocalTime y LocalDateTime

LocalTime representa una hora del día (con precisión de nanosegundos) y LocalDateTime combina una fecha y una hora. Ninguna de las dos sabe en qué lugar del mundo está.

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;

public class EjemploLocalTime {
    public static void main(String[] args) {
        // LocalTime: solo hora (sin fecha ni zona)
        LocalTime apertura = LocalTime.of(9, 0);
        LocalTime cierre = LocalTime.of(21, 30);
        LocalTime ahora = LocalTime.parse("14:45:10");
        System.out.println("Apertura " + apertura + ", cierre " + cierre);
        System.out.println("¿Abierto a las " + ahora + "? "
                + (!ahora.isBefore(apertura) && ahora.isBefore(cierre)));
        System.out.println("23:30 + 2 h = " + LocalTime.of(23, 30).plusHours(2)); // da la vuelta
        System.out.println("Truncada a horas: " + ahora.truncatedTo(ChronoUnit.HOURS));
        System.out.println("Constantes: " + LocalTime.MIDNIGHT + " " + LocalTime.NOON + " " + LocalTime.MAX);

        // LocalDateTime: fecha + hora (sin zona)
        LocalDateTime cita = LocalDateTime.of(2026, 10, 15, 10, 30);
        LocalDateTime otra = LocalDateTime.parse("2026-10-15T17:05:00");
        LocalDateTime combinada = LocalDate.of(2026, 12, 31).atTime(23, 59, 59);
        LocalDateTime inicioDia = LocalDate.of(2026, 12, 31).atStartOfDay();
        System.out.println("Cita: " + cita);
        System.out.println("Otra: " + otra);
        System.out.println("Nochevieja: " + combinada + " (inicio del día: " + inicioDia + ")");
        System.out.println("Cita + 90 min: " + cita.plusMinutes(90));
        System.out.println("Solo la fecha: " + cita.toLocalDate() + ", solo la hora: " + cita.toLocalTime());
        System.out.println("Nanosegundos: " + LocalDateTime.of(2026, 1, 1, 0, 0, 0, 123_456_789));
    }
}
Apertura 09:00, cierre 21:30
¿Abierto a las 14:45:10? true
23:30 + 2 h = 01:30
Truncada a horas: 14:00
Constantes: 00:00 12:00 23:59:59.999999999
Cita: 2026-10-15T10:30
Otra: 2026-10-15T17:05
Nochevieja: 2026-12-31T23:59:59 (inicio del día: 2026-12-31T00:00)
Cita + 90 min: 2026-10-15T12:00
Solo la fecha: 2026-10-15, solo la hora: 10:30
Nanosegundos: 2026-01-01T00:00:00.123456789

⚠️ Importante: LocalTime da la vuelta al pasar de medianoche (23:30 + 2 h = 01:30) y no te avisa de que has cambiado de día. Si el día importa, usa LocalDateTime.

💡 Consejo: El patrón !ahora.isBefore(apertura) && ahora.isBefore(cierre) comprueba un intervalo semiabierto [apertura, cierre): incluye la apertura y excluye el cierre. Es la convención de toda la API (datesUntil, between…) y evita solapamientos entre intervalos consecutivos.

toString() usa siempre el formato ISO-8601: 2026-10-15T10:30. La T separa fecha y hora; los segundos se omiten si son cero.


5. Instant: el tiempo de las máquinas

📘 Concepto: Un Instant es un punto en la línea de tiempo universal, medido en segundos y nanosegundos desde el epoch (1 de enero de 1970 a las 00:00 UTC). No tiene zona: es el mismo para todo el planeta. Su toString() siempre está en UTC y termina en Z (“Zulu”, UTC).

import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;

public class EjemploInstant {
    public static void main(String[] args) throws InterruptedException {
        Instant ahora = Instant.now();
        System.out.println("Instant.now(): " + ahora);           // siempre en UTC (la Z final)
        System.out.println("Segundos desde 1970: " + ahora.getEpochSecond());
        System.out.println("Milisegundos desde 1970: " + ahora.toEpochMilli());

        Instant epoch = Instant.EPOCH;
        Instant mil = Instant.ofEpochSecond(1_000_000_000);
        System.out.println("EPOCH: " + epoch);
        System.out.println("Segundo mil millones: " + mil);
        System.out.println("Parseado: " + Instant.parse("2026-10-04T08:00:00Z"));

        // Medir tiempos de ejecución
        Instant t0 = Instant.now();
        Thread.sleep(250);
        Instant t1 = Instant.now();
        System.out.println("Han pasado " + Duration.between(t0, t1).toMillis() + " ms");

        // El mismo instante "visto" desde dos zonas
        Instant lanzamiento = Instant.parse("2026-10-04T16:00:00Z");
        System.out.println("En Madrid: " + lanzamiento.atZone(ZoneId.of("Europe/Madrid")));
        System.out.println("En Nueva York: " + lanzamiento.atZone(ZoneId.of("America/New_York")));
        System.out.println("En Tokio: " + lanzamiento.atZone(ZoneId.of("Asia/Tokyo")));
    }
}
Instant.now(): 2026-10-04T11:16:47.795861679Z
Segundos desde 1970: 1791112607
Milisegundos desde 1970: 1791112607795
EPOCH: 1970-01-01T00:00:00Z
Segundo mil millones: 2001-09-09T01:46:40Z
Parseado: 2026-10-04T08:00:00Z
Han pasado 260 ms
En Madrid: 2026-10-04T18:00+02:00[Europe/Madrid]
En Nueva York: 2026-10-04T12:00-04:00[America/New_York]
En Tokio: 2026-10-05T01:00+09:00[Asia/Tokyo]

Fíjate en que Instant.now() se ejecutó a las 13:16 en Madrid, pero muestra 11:16Z: es la hora UTC. Y en la medición se pidieron 250 ms y se midieron 260 ms: sleep garantiza un mínimo, no un valor exacto (en tu equipo verás otro número).

Un mismo Instant visto desde Madrid, Nueva York y Tokio

Figura 2. Un único instante se corresponde con horas “de pared” distintas según la zona.

💡 Consejo: Para medir rendimiento con precisión usa System.nanoTime(), que es monotónico (nunca retrocede aunque se ajuste el reloj del sistema). Instant.now() es adecuado para marcas de tiempo (timestamps) de registros, eventos y auditoría.


6. Zonas horarias: ZoneId, ZonedDateTime y OffsetDateTime

Desfase frente a zona

Es importante distinguir dos conceptos:

Concepto Clase Ejemplo Qué sabe
Desfase (offset) ZoneOffset +02:00 Solo la diferencia con UTC en un momento
Zona horaria ZoneId Europe/Madrid Las reglas históricas y futuras: cuándo cambia la hora, qué desfase tiene cada día

España peninsular tiene desfase +01:00 en invierno y +02:00 en verano, pero su zona es siempre Europe/Madrid. Canarias es Atlantic/Canary (+00:00 / +01:00). Los identificadores siguen la base de datos IANA tz (Continente/Ciudad) que el JDK incluye y actualiza en cada versión.

⚠️ Importante: No uses abreviaturas de tres letras como CET o EST para crear zonas: son ambiguas y algunas no tienen horario de verano. Usa siempre ZoneId.of("Europe/Madrid").

Ejemplo completo

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.zone.ZoneRules;

public class EjemploZonas {
    public static void main(String[] args) {
        ZoneId madrid = ZoneId.of("Europe/Madrid");
        ZoneId canarias = ZoneId.of("Atlantic/Canary");
        System.out.println("Zona del sistema: " + ZoneId.systemDefault());
        System.out.println("Zonas disponibles: " + ZoneId.getAvailableZoneIds().size());

        // Una reunión a las 10:00 en Madrid... ¿qué hora es en Canarias y en México?
        ZonedDateTime reunion = ZonedDateTime.of(2026, 10, 20, 10, 0, 0, 0, madrid);
        System.out.println("Reunión en Madrid:   " + reunion);
        System.out.println("Misma, en Canarias:  " + reunion.withZoneSameInstant(canarias));
        System.out.println("Misma, en CDMX:      " + reunion.withZoneSameInstant(ZoneId.of("America/Mexico_City")));
        System.out.println("10:00 locales en Canarias: " + reunion.withZoneSameLocal(canarias));

        // Horario de verano 2026 en España: 29 de marzo (02:00 -> 03:00) y 25 de octubre (03:00 -> 02:00)
        ZoneRules reglas = madrid.getRules();
        System.out.println("Desfase en enero: " + reglas.getOffset(LocalDateTime.of(2026, 1, 15, 12, 0)));
        System.out.println("Desfase en julio: " + reglas.getOffset(LocalDateTime.of(2026, 7, 15, 12, 0)));
        System.out.println("Próximo cambio tras hoy: " + reglas.nextTransition(reunion.toInstant()));

        // Hueco (gap): las 02:30 del 29/03/2026 no existen en Madrid
        ZonedDateTime hueco = ZonedDateTime.of(LocalDateTime.of(2026, 3, 29, 2, 30), madrid);
        System.out.println("02:30 del 29/03 -> " + hueco);

        // Solapamiento (overlap): las 02:30 del 25/10/2026 ocurren dos veces
        ZonedDateTime solape = ZonedDateTime.of(LocalDateTime.of(2026, 10, 25, 2, 30), madrid);
        System.out.println("02:30 del 25/10 (1ª vez) -> " + solape);
        System.out.println("02:30 del 25/10 (2ª vez) -> " + solape.withLaterOffsetAtOverlap());

        // Sumar "1 día" no es lo mismo que sumar "24 horas" el día del cambio
        ZonedDateTime sabado = ZonedDateTime.of(2026, 10, 24, 12, 0, 0, 0, madrid);
        System.out.println("Sábado + 1 día:    " + sabado.plusDays(1));
        System.out.println("Sábado + 24 horas: " + sabado.plusHours(24));
        System.out.println("Horas reales entre sábado y domingo a las 12: "
                + Duration.between(sabado, sabado.plusDays(1)).toHours());

        // OffsetDateTime: desfase fijo, sin reglas de horario de verano
        OffsetDateTime odt = OffsetDateTime.of(2026, 10, 20, 10, 0, 0, 0, ZoneOffset.ofHours(2));
        System.out.println("OffsetDateTime: " + odt);
        System.out.println("En UTC: " + odt.withOffsetSameInstant(ZoneOffset.UTC));
        System.out.println("De ZonedDateTime a Offset: " + reunion.toOffsetDateTime());
    }
}
Zona del sistema: Europe/Madrid
Zonas disponibles: 604
Reunión en Madrid:   2026-10-20T10:00+02:00[Europe/Madrid]
Misma, en Canarias:  2026-10-20T09:00+01:00[Atlantic/Canary]
Misma, en CDMX:      2026-10-20T02:00-06:00[America/Mexico_City]
10:00 locales en Canarias: 2026-10-20T10:00+01:00[Atlantic/Canary]
Desfase en enero: +01:00
Desfase en julio: +02:00
Próximo cambio tras hoy: Transition[Overlap at 2026-10-25T03:00+02:00 to +01:00]
02:30 del 29/03 -> 2026-03-29T03:30+02:00[Europe/Madrid]
02:30 del 25/10 (1ª vez) -> 2026-10-25T02:30+02:00[Europe/Madrid]
02:30 del 25/10 (2ª vez) -> 2026-10-25T02:30+01:00[Europe/Madrid]
Sábado + 1 día:    2026-10-25T12:00+01:00[Europe/Madrid]
Sábado + 24 horas: 2026-10-25T11:00+01:00[Europe/Madrid]
Horas reales entre sábado y domingo a las 12: 25
OffsetDateTime: 2026-10-20T10:00+02:00
En UTC: 2026-10-20T08:00Z
De ZonedDateTime a Offset: 2026-10-20T10:00+02:00

💡 Consejo: La zona del sistema la hemos fijado al lanzar el contenedor con -e TZ=Europe/Madrid. Las imágenes de Docker usan UTC por defecto: si tu servidor muestra horas “raras”, revisa su zona. Mejor aún, no dependas de la zona del sistema: pasa siempre un ZoneId explícito.

withZoneSameInstant frente a withZoneSameLocal

  • withZoneSameInstant(z): el mismo momento visto desde otra zona (las 10:00 de Madrid son las 09:00 de Canarias). Es lo que necesitas casi siempre.
  • withZoneSameLocal(z): la misma hora de pared en otra zona (las 10:00 de Canarias, que es otro momento). Útil para “a las 10:00 hora local de cada oficina”.

El horario de verano

En la Unión Europea la hora se adelanta el último domingo de marzo y se atrasa el último domingo de octubre (a la 01:00 UTC). Esto produce dos situaciones especiales que java.time resuelve de forma documentada:

Hueco en marzo y solapamiento en octubre en la zona Europe/Madrid

Figura 3. Cambios de hora en Europe/Madrid en 2026: el hueco de marzo y el solapamiento de octubre.

Situación Qué ocurre Qué hace ZonedDateTime.of
Hueco (gap), marzo Las horas de 02:00 a 02:59 no existen Desplaza la hora hacia delante la duración del hueco: 02:30 → 03:30
Solapamiento (overlap), octubre Las horas de 02:00 a 02:59 ocurren dos veces Elige el desfase anterior (+02:00); withLaterOffsetAtOverlap() elige el posterior

⚠️ Importante: el día del cambio de octubre dura 25 horas y el de marzo, 23. Por eso plusDays(1) (conserva la hora de pared: 12:00 → 12:00) y plusHours(24) (suma 24 horas reales: 12:00 → 11:00) dan resultados distintos. Elige conscientemente: una alarma diaria quiere plusDays; un timeout de 24 h quiere plusHours o un Duration.

¿ZonedDateTime u OffsetDateTime?

  ZonedDateTime OffsetDateTime
Contiene LocalDateTime + ZoneId (+ desfase calculado) LocalDateTime + ZoneOffset
Conoce el horario de verano Sí No
Representación en texto 2026-10-20T10:00+02:00[Europe/Madrid] 2026-10-20T10:00+02:00 (estándar ISO/RFC 3339)
Uso típico Lógica de calendario, mostrar al usuario Persistencia, JSON, APIs, intercambio entre sistemas

7. Duration y Period: cantidades de tiempo

Comparación entre Duration (segundos y nanos) y Period (años, meses y días)

Figura 4. Duration mide tiempo de cronómetro; Period, tiempo de calendario.

import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Period;

public class DuracionPeriodo {
    public static void main(String[] args) {
        // Duration: cantidad de tiempo "de reloj" (segundos y nanosegundos)
        Duration pelicula = Duration.ofMinutes(142);
        System.out.println("Película: " + pelicula);                  // formato ISO-8601
        System.out.printf("Película: %d h %d min%n", pelicula.toHours(), pelicula.toMinutesPart());
        Duration jornada = Duration.between(LocalTime.of(8, 0), LocalTime.of(15, 30));
        System.out.println("Jornada: " + jornada + " = " + jornada.toMinutes() + " min");
        System.out.println("Semana laboral: " + jornada.multipliedBy(5));
        System.out.println("Duration.parse(\"PT1H30M\") = " + Duration.parse("PT1H30M").getSeconds() + " s");
        System.out.println("¿Negativa? " + Duration.between(LocalTime.NOON, LocalTime.of(9, 0)).isNegative());

        // Period: cantidad de tiempo "de calendario" (años, meses y días)
        Period contrato = Period.of(1, 6, 0);
        System.out.println("Contrato: " + contrato);
        LocalDate alta = LocalDate.of(2026, 1, 31);
        System.out.println("Fin de contrato: " + alta.plus(contrato));
        Period p = Period.between(LocalDate.of(2025, 11, 20), LocalDate.of(2026, 10, 4));
        System.out.println("Entre 20/11/2025 y 04/10/2026: " + p.getYears() + " años, "
                + p.getMonths() + " meses y " + p.getDays() + " días (" + p + ")");
        System.out.println("Period.ofWeeks(3) = " + Period.ofWeeks(3));
        System.out.println("Normalizado: " + Period.of(0, 14, 0).normalized());

        // Duration con fechas sin hora: error
        try {
            Duration.between(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 2, 1));
        } catch (Exception e) {
            System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
        }
        // Pero sí con LocalDateTime
        System.out.println("Horas de enero: "
                + Duration.between(LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 2, 1, 0, 0)).toHours());
    }
}
Película: PT2H22M
Película: 2 h 22 min
Jornada: PT7H30M = 450 min
Semana laboral: PT37H30M
Duration.parse("PT1H30M") = 5400 s
¿Negativa? true
Contrato: P1Y6M
Fin de contrato: 2027-07-31
Entre 20/11/2025 y 04/10/2026: 0 años, 10 meses y 14 días (P10M14D)
Period.ofWeeks(3) = P21D
Normalizado: P1Y2M
UnsupportedTemporalTypeException: Unsupported unit: Seconds
Horas de enero: 744

Puntos clave:

  • El formato de texto es ISO-8601: P (período) seguido de años Y, meses M y días D; luego T y horas H, minutos M y segundos S. Así, PT2H22M son 2 horas y 22 minutos y P1Y6M, un año y seis meses.
  • toMinutes() da el total de minutos (142), mientras que toMinutesPart() (Java 9) da solo la parte de minutos tras quitar las horas (22). Igual con toHoursPart(), toSecondsPart() y toDaysPart().
  • Period.ofWeeks(3) se guarda como 21 días; normalized() convierte 14 meses en 1 año y 2 meses (no convierte días en meses, porque los meses tienen longitudes distintas).
  • Duration.between con LocalDate falla: una fecha no tiene segundos. Para días entre fechas usa Period o ChronoUnit.DAYS (siguiente sección).

8. ChronoUnit y cálculos habituales

ChronoUnit es un enumerado con las unidades de tiempo (NANOS, MILLIS, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, DECADES…). Su método between(a, b) devuelve la cantidad completa de esa unidad entre dos objetos temporales, como un long.

💻 Ejemplo: edad, días entre fechas, minutos trabajados y suma de días hábiles.

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Period;
import java.time.temporal.ChronoUnit;
import java.util.Set;

public class Calculos {

    // Festivos nacionales de 2026 (ejemplo simplificado)
    static final Set<LocalDate> FESTIVOS = Set.of(
            LocalDate.of(2026, 10, 12),   // Fiesta Nacional
            LocalDate.of(2026, 12, 8),    // Inmaculada Concepción
            LocalDate.of(2026, 12, 25));  // Navidad

    public static void main(String[] args) {
        LocalDate hoy = LocalDate.of(2026, 10, 4);

        // 1. Edad de una persona
        LocalDate nacimiento = LocalDate.of(2005, 10, 5);
        System.out.println("Edad (ChronoUnit): " + ChronoUnit.YEARS.between(nacimiento, hoy));
        System.out.println("Edad (Period):     " + Period.between(nacimiento, hoy).getYears());
        System.out.println("Edad mañana:       " + edad(nacimiento, hoy.plusDays(1)));

        // 2. Días entre dos fechas
        LocalDate finCurso = LocalDate.of(2027, 6, 22);
        System.out.println("Días hasta fin de curso: " + ChronoUnit.DAYS.between(hoy, finCurso));
        System.out.println("Semanas completas:       " + ChronoUnit.WEEKS.between(hoy, finCurso));
        System.out.println("Meses completos:         " + ChronoUnit.MONTHS.between(hoy, finCurso));
        System.out.println("Con until():             " + hoy.until(finCurso, ChronoUnit.DAYS));
        System.out.println("Al revés (negativo):     " + ChronoUnit.DAYS.between(finCurso, hoy));

        // 3. Minutos entre dos momentos
        LocalDateTime entrada = LocalDateTime.of(2026, 10, 5, 7, 58);
        LocalDateTime salida = LocalDateTime.of(2026, 10, 5, 15, 3);
        System.out.println("Minutos trabajados: " + ChronoUnit.MINUTES.between(entrada, salida));

        // 4. Sumar días hábiles
        LocalDate pedido = LocalDate.of(2026, 10, 8); // jueves
        System.out.println("Pedido el " + pedido + " (" + pedido.getDayOfWeek() + ")");
        LocalDate entrega = sumarDiasHabiles(pedido, 5);
        System.out.println("Entrega en 5 días hábiles: " + entrega + " (" + entrega.getDayOfWeek() + ")");
        System.out.println("Días hábiles de octubre: "
                + diasHabilesEntre(LocalDate.of(2026, 10, 1), LocalDate.of(2026, 11, 1)));
    }

    static int edad(LocalDate nacimiento, LocalDate referencia) {
        return Period.between(nacimiento, referencia).getYears();
    }

    static boolean esHabil(LocalDate f) {
        DayOfWeek d = f.getDayOfWeek();
        return d != DayOfWeek.SATURDAY && d != DayOfWeek.SUNDAY && !FESTIVOS.contains(f);
    }

    static LocalDate sumarDiasHabiles(LocalDate desde, int dias) {
        LocalDate f = desde;
        int sumados = 0;
        while (sumados < dias) {
            f = f.plusDays(1);
            if (esHabil(f)) {
                sumados++;
            }
        }
        return f;
    }

    // [inicio, fin): el fin no se incluye, igual que en datesUntil
    static long diasHabilesEntre(LocalDate inicio, LocalDate fin) {
        return inicio.datesUntil(fin).filter(Calculos::esHabil).count();
    }
}
Edad (ChronoUnit): 20
Edad (Period):     20
Edad mañana:       21
Días hasta fin de curso: 261
Semanas completas:       37
Meses completos:         8
Con until():             261
Al revés (negativo):     -261
Minutos trabajados: 425
Pedido el 2026-10-08 (THURSDAY)
Entrega en 5 días hábiles: 2026-10-16 (FRIDAY)
Días hábiles de octubre: 21

Analiza el cálculo de la entrega: desde el jueves 8 se cuentan el viernes 9 (1), se saltan sábado y domingo, se salta el lunes 12 (festivo), y se cuentan martes 13 (2), miércoles 14 (3), jueves 15 (4) y viernes 16 (5).

📘 Concepto: ChronoUnit.X.between(a, b) cuenta unidades completas y trunca: entre el 4 de octubre y el 22 de junio hay 8 meses completos (el noveno no ha terminado). Por eso la persona nacida el 5 de octubre de 2005 tiene 20 años hoy y cumple 21 mañana. Es exactamente la regla legal de la edad.

⚠️ Importante: No calcules la edad como hoy.getYear() - nacimiento.getYear(): daría 21 aunque la persona aún no haya cumplido años. Y no calcules días como “milisegundos / 86 400 000”: falla los días de cambio de hora.

Necesito… Usa
Un número total en una unidad (días, horas…) ChronoUnit.DAYS.between(a, b) o a.until(b, ChronoUnit.DAYS)
“X años, Y meses y Z días” Period.between(fechaA, fechaB)
“X horas y Y minutos” Duration.between(a, b) y sus métodos toXxxPart()
Iterar día a día a.datesUntil(b) (stream, Java 9)

9. TemporalAdjusters: “el primer lunes del mes”

Un ajustador temporal (TemporalAdjuster) es una función que recibe una fecha y devuelve otra. Se aplica con with(...). La clase TemporalAdjusters (en plural) trae los más comunes, y puedes crear los tuyos con una lambda.

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjuster;
import java.time.temporal.TemporalAdjusters;

import static java.time.temporal.TemporalAdjusters.*;

public class Ajustadores {
    public static void main(String[] args) {
        LocalDate hoy = LocalDate.of(2026, 10, 4); // domingo
        System.out.println("Hoy:                       " + hoy + " " + hoy.getDayOfWeek());
        System.out.println("Primer día del mes:        " + hoy.with(firstDayOfMonth()));
        System.out.println("Último día del mes:        " + hoy.with(lastDayOfMonth()));
        System.out.println("Primer día del mes que viene: " + hoy.with(firstDayOfNextMonth()));
        System.out.println("Último día del año:        " + hoy.with(lastDayOfYear()));
        System.out.println("Primer lunes del mes:      " + hoy.with(firstInMonth(DayOfWeek.MONDAY)));
        System.out.println("Último viernes del mes:    " + hoy.with(lastInMonth(DayOfWeek.FRIDAY)));
        System.out.println("Tercer miércoles del mes:  " + hoy.with(dayOfWeekInMonth(3, DayOfWeek.WEDNESDAY)));
        System.out.println("Próximo lunes:             " + hoy.with(next(DayOfWeek.MONDAY)));
        System.out.println("Próximo domingo o hoy:     " + hoy.with(nextOrSame(DayOfWeek.SUNDAY)));
        System.out.println("Lunes anterior:            " + hoy.with(previous(DayOfWeek.MONDAY)));

        // Ajustador propio: siguiente día laborable (lunes a viernes)
        TemporalAdjuster siguienteLaborable = TemporalAdjusters.ofDateAdjuster(f -> {
            LocalDate s = f.plusDays(1);
            while (s.getDayOfWeek() == DayOfWeek.SATURDAY || s.getDayOfWeek() == DayOfWeek.SUNDAY) {
                s = s.plusDays(1);
            }
            return s;
        });
        LocalDate viernes = LocalDate.of(2026, 10, 9);
        System.out.println("Laborable siguiente al viernes " + viernes + ": " + viernes.with(siguienteLaborable));

        // Día de pago: último día laborable del mes
        for (int mes = 1; mes <= 12; mes += 4) {
            LocalDate ultimo = LocalDate.of(2026, mes, 1).with(lastDayOfMonth());
            while (ultimo.getDayOfWeek().getValue() > 5) {
                ultimo = ultimo.minusDays(1);
            }
            System.out.println("Día de pago de " + ultimo.getMonth() + ": " + ultimo + " " + ultimo.getDayOfWeek());
        }
    }
}
Hoy:                       2026-10-04 SUNDAY
Primer día del mes:        2026-10-01
Último día del mes:        2026-10-31
Primer día del mes que viene: 2026-11-01
Último día del año:        2026-12-31
Primer lunes del mes:      2026-10-05
Último viernes del mes:    2026-10-30
Tercer miércoles del mes:  2026-10-21
Próximo lunes:             2026-10-05
Próximo domingo o hoy:     2026-10-04
Lunes anterior:            2026-09-28
Laborable siguiente al viernes 2026-10-09: 2026-10-12
Día de pago de JANUARY: 2026-01-30 FRIDAY
Día de pago de MAY: 2026-05-29 FRIDAY
Día de pago de SEPTEMBER: 2026-09-30 WEDNESDAY
Ajustador Resultado
firstDayOfMonth() / lastDayOfMonth() Primer / último día del mes
firstDayOfNextMonth() Día 1 del mes siguiente
firstDayOfYear() / lastDayOfYear() / firstDayOfNextYear() Inicio / fin del año, inicio del siguiente
firstInMonth(d) / lastInMonth(d) Primer / último día de la semana d del mes
dayOfWeekInMonth(n, d) El enésimo día d del mes (p. ej. 3.er miércoles)
next(d) / nextOrSame(d) Próximo día d (sin contar / contando hoy)
previous(d) / previousOrSame(d) Anterior día d
ofDateAdjuster(lambda) Ajustador personalizado a partir de UnaryOperator<LocalDate>

💡 Consejo: La importación estática import static java.time.temporal.TemporalAdjusters.*; hace que el código se lea casi como lenguaje natural: hoy.with(lastInMonth(FRIDAY)).


10. DateTimeFormatter: formatear y parsear

DateTimeFormatter convierte objetos temporales en texto (format) y texto en objetos (parse). A diferencia de SimpleDateFormat, es inmutable y seguro entre hilos: puedes guardarlo en una constante static final y compartirlo.

Tres formas de obtener un formateador

  1. Predefinidos ISO: DateTimeFormatter.ISO_LOCAL_DATE, ISO_LOCAL_DATE_TIME, ISO_OFFSET_DATE_TIME, ISO_INSTANT, BASIC_ISO_DATE…
  2. Patrones: DateTimeFormatter.ofPattern("dd/MM/yyyy", locale).
  3. Estilos localizados: DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(locale).

Tabla de letras de los patrones

Letra Significado Ejemplo (domingo 4/10/2026 09:05:07 en Madrid, es-ES)
uuuu Año (proléptico, admite negativos) 2026
yyyy / yy Año de la era / dos cifras 2026 / 26
M / MM Mes en número 10 / 10
MMM / MMMM Mes en texto abreviado / completo oct / octubre
d / dd Día del mes 4 / 04
D Día del año 277
E / EEEE Día de la semana abreviado / completo dom / domingo
e Día de la semana como número local 7 (en España la semana empieza en lunes)
H / HH Hora 0-23 9 / 09
h / hh Hora 1-12 9 / 09
a a. m. / p. m. a. m.
m / mm Minutos 5 / 05
s / ss Segundos 7 / 07
S...S Fracción de segundo SSS → milisegundos
Q / QQQQ Trimestre 4 / 4.º trimestre
w Semana del año (según el Locale) 40
VV Identificador de zona Europe/Madrid
z Nombre corto de la zona CEST
xxx / XXX Desfase (X usa Z para cero) +02:00
'texto' Texto literal 'de' → de

⚠️ Importante: Mayúsculas y minúsculas importan: MM es mes y mm minutos; HH es hora 0-23 y hh 1-12; DD es día del año (¡no del mes!). Confundirlas es el error más frecuente al formatear fechas.

Ejemplo completo

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.FormatStyle;
import java.time.format.ResolverStyle;
import java.time.format.TextStyle;
import java.util.Locale;

public class Formateo {
    public static void main(String[] args) {
        Locale es = Locale.of("es", "ES");   // Locale.of existe desde Java 19
        LocalDateTime t = LocalDateTime.of(2026, 10, 4, 9, 5, 7);
        ZonedDateTime z = t.atZone(ZoneId.of("Europe/Madrid"));

        // Formateadores predefinidos (ISO)
        System.out.println("ISO_LOCAL_DATE:      " + t.format(DateTimeFormatter.ISO_LOCAL_DATE));
        System.out.println("ISO_LOCAL_DATE_TIME: " + t.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
        System.out.println("ISO_OFFSET_DATE_TIME:" + z.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
        System.out.println("BASIC_ISO_DATE:      " + t.format(DateTimeFormatter.BASIC_ISO_DATE));

        // Patrones propios
        String[] patrones = {
                "dd/MM/yyyy", "d/M/yy", "dd-MM-yyyy HH:mm:ss", "EEEE, d 'de' MMMM 'de' yyyy",
                "EEE d MMM", "hh:mm a", "H:mm", "'Semana' w", "QQQQ", "dd/MM/yyyy HH:mm z", "xxx VV"
        };
        for (String p : patrones) {
            DateTimeFormatter f = DateTimeFormatter.ofPattern(p, es);
            System.out.printf("%-30s -> %s%n", p, z.format(f));
        }

        // Estilos localizados
        for (FormatStyle estilo : FormatStyle.values()) {
            String texto = estilo.ordinal() < 2
                    ? z.format(DateTimeFormatter.ofLocalizedDate(estilo).withLocale(es))
                    : t.format(DateTimeFormatter.ofLocalizedDateTime(estilo).withLocale(es));
            System.out.printf("%-7s -> %s%n", estilo, texto);
        }
        System.out.println("Inglés EE. UU.: " + t.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(Locale.US)));
        System.out.println("Nombre del mes: " + t.getMonth().getDisplayName(TextStyle.FULL, es)
                + ", día: " + t.getDayOfWeek().getDisplayName(TextStyle.SHORT, es));

        // Parseo
        DateTimeFormatter entrada = DateTimeFormatter.ofPattern("dd/MM/yyyy");
        LocalDate fecha = LocalDate.parse("25/12/2026", entrada);
        System.out.println("Parseada: " + fecha);
        LocalDateTime ldt = LocalDateTime.parse("15/10/2026 18:30", DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"));
        System.out.println("Parseada con hora: " + ldt);

        String[] malas = {"2026-12-25", "32/01/2026", "29/02/2026", "31/04/2026"};
        for (String m : malas) {
            try {
                System.out.println(m + " -> " + LocalDate.parse(m, entrada));
            } catch (DateTimeParseException e) {
                System.out.println(m + " -> ERROR: " + e.getMessage());
            }
        }

        // Modo estricto: exige 'uuuu' (año proléptico) en lugar de 'yyyy' (año de la era)
        DateTimeFormatter estricto = DateTimeFormatter.ofPattern("dd/MM/uuuu").withResolverStyle(ResolverStyle.STRICT);
        try {
            LocalDate.parse("31/04/2026", estricto);
        } catch (DateTimeParseException e) {
            System.out.println("Estricto 31/04/2026 -> ERROR: " + e.getMessage());
        }
        try {
            LocalDate.parse("01/01/2026", DateTimeFormatter.ofPattern("dd/MM/yyyy").withResolverStyle(ResolverStyle.STRICT));
        } catch (DateTimeParseException e) {
            System.out.println("Estricto con yyyy -> ERROR: " + e.getMessage());
        }
    }
}
ISO_LOCAL_DATE:      2026-10-04
ISO_LOCAL_DATE_TIME: 2026-10-04T09:05:07
ISO_OFFSET_DATE_TIME:2026-10-04T09:05:07+02:00
BASIC_ISO_DATE:      20261004
dd/MM/yyyy                     -> 04/10/2026
d/M/yy                         -> 4/10/26
dd-MM-yyyy HH:mm:ss            -> 04-10-2026 09:05:07
EEEE, d 'de' MMMM 'de' yyyy    -> domingo, 4 de octubre de 2026
EEE d MMM                      -> dom 4 oct
hh:mm a                        -> 09:05 a. m.
H:mm                           -> 9:05
'Semana' w                     -> Semana 40
QQQQ                           -> 4.º trimestre
dd/MM/yyyy HH:mm z             -> 04/10/2026 09:05 CEST
xxx VV                         -> +02:00 Europe/Madrid
FULL    -> domingo, 4 de octubre de 2026
LONG    -> 4 de octubre de 2026
MEDIUM  -> 4 oct 2026, 9:05:07
SHORT   -> 4/10/26, 9:05
Inglés EE. UU.: Sunday, October 4, 2026
Nombre del mes: octubre, día: dom
Parseada: 2026-12-25
Parseada con hora: 2026-10-15T18:30
2026-12-25 -> ERROR: Text '2026-12-25' could not be parsed at index 2
32/01/2026 -> ERROR: Text '32/01/2026' could not be parsed: Invalid value for DayOfMonth (valid values 1 - 28/31): 32
29/02/2026 -> 2026-02-28
31/04/2026 -> 2026-04-30
Estricto 31/04/2026 -> ERROR: Text '31/04/2026' could not be parsed: Invalid date 'APRIL 31'
Estricto con yyyy -> ERROR: Text '01/01/2026' could not be parsed: Unable to obtain LocalDate from TemporalAccessor: {MonthOfYear=1, DayOfMonth=1, YearOfEra=2026},ISO of type java.time.format.Parsed

Locale español

Los nombres de meses y días, las abreviaturas (dom, oct), el texto a. m. o el formato 4/10/26 salen de los datos CLDR (Common Locale Data Repository) que incluye el JDK. Por eso:

  • Pasa siempre el Locale explícito a ofPattern(patron, locale) o con withLocale(locale). Si no, se usa el del sistema, y un servidor en inglés mostrará Sunday en tu web en español.
  • Locale.of("es", "ES") es la forma moderna (Java 19); los constructores new Locale(...) están obsoletos desde entonces. También puedes usar Locale.forLanguageTag("es-ES").
  • Para nombres sueltos usa getDisplayName(TextStyle.FULL, locale) de Month y DayOfWeek.

💡 Consejo: Los textos de CLDR pueden cambiar ligeramente entre versiones del JDK (por ejemplo, el punto en las abreviaturas o el espacio de a. m.). Por eso en las pruebas automáticas compara objetos de fecha, no cadenas formateadas, o fija el formato con un patrón explícito.

Parseo y DateTimeParseException

parse lanza DateTimeParseException (una RuntimeException) cuando el texto no encaja. El mensaje indica en qué posición (index 2) falló. Atrápala siempre que el texto venga del usuario.

Fíjate en un detalle sorprendente de la salida: con el patrón dd/MM/yyyy, 29/02/2026 se convirtió en el 28 de febrero y 31/04/2026 en el 30 de abril. El motivo es el estilo de resolución por defecto:

ResolverStyle Comportamiento con 31/04/2026
STRICT Error: el 31 de abril no existe
SMART (por defecto) Ajusta el día al último válido del mes: 30/04/2026 (pero rechaza el día 32)
LENIENT Desborda: 01/05/2026

⚠️ Importante: Para validar entradas de usuario usa ResolverStyle.STRICT con uuuu. En modo estricto yyyy es el “año de la era” y necesita también la era (G), por eso dd/MM/yyyy estricto falla incluso con una fecha correcta, como muestra la última línea.

private static final DateTimeFormatter FORMATO_ES =
        DateTimeFormatter.ofPattern("dd/MM/uuuu").withResolverStyle(ResolverStyle.STRICT);

11. Convivir con las clases heredadas

Tarde o temprano recibirás un java.util.Date de una biblioteca antigua o un java.sql.Timestamp de JDBC. Java 8 añadió métodos puente a las clases antiguas para convertir sin perder información.

import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.TimeZone;

public class Conversiones {
    public static void main(String[] args) {
        ZoneId zona = ZoneId.of("Europe/Madrid");

        // java.util.Date <-> Instant (el puente natural: ambos son instantes)
        Date antigua = new Date(1_791_100_800_000L);
        Instant instante = antigua.toInstant();
        Date otraVez = Date.from(instante);
        System.out.println("Date -> Instant: " + instante);
        System.out.println("Instant -> Date: " + otraVez);

        // Date -> LocalDate / LocalDateTime: hace falta una zona
        LocalDate ld = antigua.toInstant().atZone(zona).toLocalDate();
        LocalDateTime ldt = LocalDateTime.ofInstant(antigua.toInstant(), zona);
        System.out.println("Date -> LocalDate: " + ld + ", LocalDateTime: " + ldt);

        // LocalDateTime -> Date
        Date desdeLocal = Date.from(LocalDateTime.of(2026, 12, 25, 20, 0).atZone(zona).toInstant());
        System.out.println("LocalDateTime -> Date: " + desdeLocal);

        // Calendar <-> ZonedDateTime
        GregorianCalendar cal = GregorianCalendar.from(ZonedDateTime.of(2026, 5, 1, 9, 0, 0, 0, zona));
        System.out.println("Calendar: mes=" + cal.get(Calendar.MONTH) + " (base 0), " + cal.getTimeZone().getID());
        ZonedDateTime zdt = cal.toZonedDateTime();
        System.out.println("Calendar -> ZonedDateTime: " + zdt);
        TimeZone tz = TimeZone.getTimeZone(zona);
        System.out.println("ZoneId -> TimeZone: " + tz.getID() + " -> " + tz.toZoneId());

        // java.sql.Date, Time y Timestamp (JDBC antiguo)
        java.sql.Date sqlDate = java.sql.Date.valueOf(LocalDate.of(2026, 10, 4));
        java.sql.Time sqlTime = java.sql.Time.valueOf(java.time.LocalTime.of(18, 30));
        Timestamp ts = Timestamp.valueOf(LocalDateTime.of(2026, 10, 4, 18, 30, 15, 500_000_000));
        System.out.println("sql.Date: " + sqlDate + " -> " + sqlDate.toLocalDate());
        System.out.println("sql.Time: " + sqlTime + " -> " + sqlTime.toLocalTime());
        System.out.println("Timestamp: " + ts + " -> " + ts.toLocalDateTime() + " / " + ts.toInstant());
        System.out.println("Timestamp.from(Instant): " + Timestamp.from(Instant.parse("2026-01-01T00:00:00Z")));
    }
}
Date -> Instant: 2026-10-04T08:00:00Z
Instant -> Date: Sun Oct 04 10:00:00 CEST 2026
Date -> LocalDate: 2026-10-04, LocalDateTime: 2026-10-04T10:00
LocalDateTime -> Date: Fri Dec 25 20:00:00 CET 2026
Calendar: mes=4 (base 0), Europe/Madrid
Calendar -> ZonedDateTime: 2026-05-01T09:00+02:00[Europe/Madrid]
ZoneId -> TimeZone: Europe/Madrid -> Europe/Madrid
sql.Date: 2026-10-04 -> 2026-10-04
sql.Time: 18:30:00 -> 18:30
Timestamp: 2026-10-04 18:30:15.5 -> 2026-10-04T18:30:15.500 / 2026-10-04T16:30:15.500Z
Timestamp.from(Instant): 2026-01-01 01:00:00.0
De A Método
Date Instant date.toInstant()
Instant Date Date.from(instant)
Date LocalDate date.toInstant().atZone(zona).toLocalDate()
GregorianCalendar ZonedDateTime cal.toZonedDateTime() / GregorianCalendar.from(zdt)
TimeZone ZoneId tz.toZoneId() / TimeZone.getTimeZone(zoneId)
java.sql.Date LocalDate sqlDate.toLocalDate() / java.sql.Date.valueOf(ld)
java.sql.Time LocalTime sqlTime.toLocalTime() / Time.valueOf(lt)
java.sql.Timestamp LocalDateTime / Instant ts.toLocalDateTime(), ts.toInstant() / Timestamp.valueOf(ldt), Timestamp.from(i)

Fechas en JDBC moderno

Desde JDBC 4.2 (Java 8) los drivers aceptan directamente los tipos de java.time con setObject y getObject, así que ya no necesitas java.sql.Date ni Timestamp. Lo usarás en el módulo 12:

// Escribir
ps.setObject(1, LocalDate.of(2026, 10, 4));
// Leer
LocalDate fecha = rs.getObject("fecha_pedido", LocalDate.class);
LocalDateTime creado = rs.getObject("creado_en", LocalDateTime.class);
Tipo SQL (MySQL) Tipo Java recomendado
DATE LocalDate
TIME LocalTime
DATETIME LocalDateTime
TIMESTAMP LocalDateTime / Instant (según el driver y su configuración de zona)
TIMESTAMP WITH TIME ZONE (estándar, p. ej. PostgreSQL) OffsetDateTime

⚠️ Importante: java.sql.Date hereda de java.util.Date pero representa solo una fecha; java.sql.Timestamp añade nanosegundos. Son de los peores diseños del JDK (rompen la simetría de equals). Úsalos únicamente en la frontera con código antiguo.


12. Relojes inyectables: Clock

Imagina que tienes que probar un método que aplica un descuento “los viernes por la tarde”. Si el método llama a LocalDate.now(), ¡solo podrías probarlo los viernes por la tarde! La solución es inyectar el reloj.

📘 Concepto: java.time.Clock es una abstracción del reloj. Todos los métodos now() tienen una versión now(Clock). En producción pasas Clock.systemDefaultZone() o Clock.system(zona); en las pruebas, un Clock.fixed(...) que siempre devuelve el mismo instante. Es un ejemplo de inyección de dependencias, que verás a fondo con Spring en el módulo 17.

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;

public class Relojes {

    // Clase de negocio que NO llama a LocalDate.now() directamente: recibe un Clock
    static class ServicioDescuentos {
        private final Clock reloj;

        ServicioDescuentos(Clock reloj) {
            this.reloj = reloj;
        }

        // 20 % de descuento los viernes por la tarde ("happy hour")
        int porcentajeDescuento() {
            LocalDate hoy = LocalDate.now(reloj);
            LocalTime hora = LocalTime.now(reloj);
            boolean viernes = hoy.getDayOfWeek() == java.time.DayOfWeek.FRIDAY;
            return viernes && hora.isAfter(LocalTime.of(16, 0)) ? 20 : 0;
        }
    }

    public static void main(String[] args) {
        ZoneId madrid = ZoneId.of("Europe/Madrid");

        // En producción: reloj del sistema
        ServicioDescuentos real = new ServicioDescuentos(Clock.system(madrid));
        System.out.println("Descuento ahora mismo: " + real.porcentajeDescuento() + " %");

        // En pruebas: relojes fijos, deterministas
        Clock viernesTarde = Clock.fixed(Instant.parse("2026-10-09T15:30:00Z"), madrid); // 17:30 en Madrid
        Clock viernesManana = Clock.fixed(Instant.parse("2026-10-09T08:00:00Z"), madrid);
        Clock lunes = Clock.fixed(Instant.parse("2026-10-12T15:30:00Z"), madrid);
        comprobar("viernes tarde", new ServicioDescuentos(viernesTarde).porcentajeDescuento(), 20);
        comprobar("viernes mañana", new ServicioDescuentos(viernesManana).porcentajeDescuento(), 0);
        comprobar("lunes", new ServicioDescuentos(lunes).porcentajeDescuento(), 0);

        // Relojes desplazados: "viajar en el tiempo"
        Clock dentroDeUnaSemana = Clock.offset(viernesTarde, Duration.ofDays(7));
        System.out.println("Fecha con reloj desplazado: " + LocalDate.now(dentroDeUnaSemana));
        System.out.println("Instant fijo: " + Instant.now(viernesTarde) + ", millis=" + viernesTarde.millis());
    }

    static void comprobar(String caso, int obtenido, int esperado) {
        System.out.printf("%-15s -> %2d %%  %s%n", caso, obtenido, obtenido == esperado ? "OK" : "FALLO");
    }
}
Descuento ahora mismo: 0 %
viernes tarde   -> 20 %  OK
viernes mañana  ->  0 %  OK
lunes           ->  0 %  OK
Fecha con reloj desplazado: 2026-10-16
Instant fijo: 2026-10-09T15:30:00Z, millis=1791559800000
Fábrica Comportamiento
Clock.systemUTC() Reloj real en UTC
Clock.systemDefaultZone() / Clock.system(zona) Reloj real en la zona indicada
Clock.fixed(instant, zona) Siempre devuelve el mismo instante (pruebas)
Clock.offset(reloj, duracion) Otro reloj desplazado una duración
Clock.tick(reloj, duracion) Reloj que avanza “a saltos” (p. ej. de minuto en minuto)

💡 Consejo: Regla profesional: ninguna clase de negocio debería llamar a now() sin argumentos. Recibe un Clock en el constructor. Tus pruebas de JUnit (módulo 11) serán deterministas y podrás probar fines de mes, años bisiestos y cambios de hora sin esperar a que lleguen.


13. Internacionalización básica

La internacionalización (i18n: una “i”, 18 letras y una “n”) consiste en preparar la aplicación para distintos idiomas y regiones; la localización (l10n) es adaptarla a uno concreto. En Java el protagonista es java.util.Locale, que combina idioma (es), país (ES) y opcionalmente variantes.

Números, moneda y porcentajes con NumberFormat

import java.math.BigDecimal;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Currency;
import java.util.Locale;
import java.util.ResourceBundle;

public class Internacionalizacion {
    public static void main(String[] args) throws ParseException {
        Locale es = Locale.of("es", "ES");
        Locale mx = Locale.of("es", "MX");
        Locale us = Locale.US;
        Locale de = Locale.GERMANY;
        System.out.println("Locale por defecto: " + Locale.getDefault());
        System.out.println("es-ES: idioma=" + es.getDisplayLanguage(es) + ", país=" + es.getDisplayCountry(es)
                + ", etiqueta=" + es.toLanguageTag());
        System.out.println("Desde etiqueta: " + Locale.forLanguageTag("ca-ES").getDisplayName(es));

        double importe = 1234567.891;
        BigDecimal precio = new BigDecimal("1499.90");

        for (Locale l : new Locale[]{es, mx, us, de}) {
            NumberFormat numero = NumberFormat.getNumberInstance(l);
            NumberFormat moneda = NumberFormat.getCurrencyInstance(l);
            NumberFormat porcentaje = NumberFormat.getPercentInstance(l);
            porcentaje.setMaximumFractionDigits(1);
            System.out.printf("%-6s número=%-14s moneda=%-15s precio=%-12s %%=%s%n",
                    l.toLanguageTag(), numero.format(importe), moneda.format(importe),
                    moneda.format(precio), porcentaje.format(0.2155));
        }

        // Mostrar euros con las convenciones de EE. UU.
        NumberFormat eurosEnUs = NumberFormat.getCurrencyInstance(us);
        eurosEnUs.setCurrency(Currency.getInstance("EUR"));
        System.out.println("Euros con formato EE. UU.: " + eurosEnUs.format(precio));
        System.out.println("Símbolo del euro en es-ES: " + Currency.getInstance("EUR").getSymbol(es));

        // Números compactos (Java 12)
        NumberFormat compacto = NumberFormat.getCompactNumberInstance(es, NumberFormat.Style.SHORT);
        compacto.setMaximumFractionDigits(1);
        System.out.println("Compacto: " + compacto.format(1_500) + " | " + compacto.format(2_300_000));

        // Parsear un número escrito "a la española"
        Number n = NumberFormat.getNumberInstance(es).parse("1.250,75");
        System.out.println("Parseado: " + n.doubleValue());

        // ResourceBundle: textos de la interfaz en ficheros .properties
        for (Locale l : new Locale[]{es, us, Locale.FRANCE}) {
            ResourceBundle textos = ResourceBundle.getBundle("mensajes", l);
            System.out.println(l + " -> " + textos.getString("saludo") + " | "
                    + new java.text.MessageFormat(textos.getString("carrito"), l).format(new Object[]{3, 59.97}));
        }
    }
}

Junto al programa hay dos ficheros de recursos (en un proyecto Maven irían en src/main/resources):

# mensajes.properties (fichero base: se usa cuando no hay uno más específico)
saludo=Hola
carrito=Tienes {0} productos en el carrito ({1,number,#.##} €)
# mensajes_en.properties
saludo=Hello
carrito=You have {0} items in your cart ({1,number,#.##} EUR)

Salida real (con Locale por defecto es_ES):

Locale por defecto: es_ES
es-ES: idioma=español, país=España, etiqueta=es-ES
Desde etiqueta: catalán (España)
es-ES  número=1.234.567,891  moneda=1.234.567,89 €  precio=1.499,90 €   %=21,6 %
es-MX  número=1,234,567.891  moneda=$1,234,567.89   precio=$1,499.90    %=21.6%
en-US  número=1,234,567.891  moneda=$1,234,567.89   precio=$1,499.90    %=21.6%
de-DE  número=1.234.567,891  moneda=1.234.567,89 €  precio=1.499,90 €   %=21,6 %
Euros con formato EE. UU.: €1,499.90
Símbolo del euro en es-ES: €
Compacto: 1,5 mil | 2,3 M
Parseado: 1250.75
es_ES -> Hola | Tienes 3 productos en el carrito (59,97 €)
en_US -> Hello | You have 3 items in your cart (59.97 EUR)
fr_FR -> Hola | Tienes 3 productos en el carrito (59,97 €)

Observaciones:

  • En España el separador de miles es el punto, el decimal la coma y el símbolo € va detrás, separado por un espacio. Ese espacio (y el de 21,6 %) es un espacio de no separación (U+00A0), no un espacio normal: tenlo en cuenta si comparas cadenas en pruebas.
  • getPercentInstance multiplica por 100: 0.2155 → 21,6 %.
  • La moneda depende del país del Locale (es-MX usa pesos con $). Si tu tienda cobra en euros a clientes de cualquier país, fija la moneda con setCurrency(Currency.getInstance("EUR")).
  • Para dinero usa BigDecimal, nunca double (verás por qué en el ejemplo de nóminas).
  • Las instancias de NumberFormat no son seguras entre hilos: crea una por uso o por hilo.

ResourceBundle: textos traducibles

ResourceBundle.getBundle("mensajes", locale) busca, en el classpath, el fichero más específico: mensajes_en_US.properties, luego mensajes_en.properties, luego los del Locale por defecto y por último mensajes.properties. Por eso para fr_FR (sin fichero francés) se usó el base en español.

💡 Consejo: Desde Java 9 los ficheros .properties de ResourceBundle se leen en UTF-8, así que puedes escribir tildes y € directamente. Usa MessageFormat con el Locale adecuado para insertar valores ({0}, {1,number,#.##}), y no concatenes trozos de frases: el orden de las palabras cambia entre idiomas.


14. Ejemplos completos

14.1. Agenda de citas de una consulta

Una consulta médica atiende de lunes a viernes de 9:00 a 14:00 en huecos de 30 minutos. La agenda debe rechazar citas en el pasado, en fin de semana, fuera de horario o que se solapen con otras, listar las citas de la semana y calcular el recordatorio para un paciente que vive en Canarias.

import java.time.Clock;
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Optional;

public class AgendaCitas {

    record Cita(String paciente, LocalDateTime inicio, Duration duracion) {
        LocalDateTime fin() {
            return inicio.plus(duracion);
        }

        boolean seSolapaCon(Cita otra) {
            // [inicio, fin) se solapa con [otra.inicio, otra.fin) si cada una empieza antes de que acabe la otra
            return inicio.isBefore(otra.fin()) && otra.inicio.isBefore(fin());
        }
    }

    static class Agenda {
        static final LocalTime APERTURA = LocalTime.of(9, 0);
        static final LocalTime CIERRE = LocalTime.of(14, 0);
        static final Duration HUECO = Duration.ofMinutes(30);

        private final List<Cita> citas = new ArrayList<>();
        private final Clock reloj;

        Agenda(Clock reloj) {
            this.reloj = reloj;
        }

        void reservar(String paciente, LocalDateTime inicio, Duration duracion) {
            Cita nueva = new Cita(paciente, inicio, duracion);
            if (inicio.isBefore(LocalDateTime.now(reloj))) {
                throw new IllegalArgumentException("No se puede reservar en el pasado: " + inicio);
            }
            DayOfWeek d = inicio.getDayOfWeek();
            if (d == DayOfWeek.SATURDAY || d == DayOfWeek.SUNDAY) {
                throw new IllegalArgumentException("La consulta cierra los fines de semana");
            }
            if (inicio.toLocalTime().isBefore(APERTURA) || nueva.fin().toLocalTime().isAfter(CIERRE)) {
                throw new IllegalArgumentException("Fuera del horario de 9:00 a 14:00");
            }
            Optional<Cita> conflicto = citas.stream().filter(nueva::seSolapaCon).findFirst();
            if (conflicto.isPresent()) {
                throw new IllegalArgumentException("Se solapa con la cita de " + conflicto.get().paciente());
            }
            citas.add(nueva);
        }

        List<LocalTime> huecosLibres(LocalDate dia) {
            List<LocalTime> libres = new ArrayList<>();
            for (LocalTime h = APERTURA; h.isBefore(CIERRE); h = h.plus(HUECO)) {
                Cita prueba = new Cita("?", dia.atTime(h), HUECO);
                if (citas.stream().noneMatch(prueba::seSolapaCon)) {
                    libres.add(h);
                }
            }
            return libres;
        }

        List<Cita> citasDeLaSemana(LocalDate cualquierDia) {
            LocalDate lunes = cualquierDia.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
            LocalDate lunesSiguiente = lunes.plusWeeks(1);
            return citas.stream()
                    .filter(c -> !c.inicio().toLocalDate().isBefore(lunes)
                            && c.inicio().toLocalDate().isBefore(lunesSiguiente))
                    .sorted(Comparator.comparing(Cita::inicio))
                    .toList();
        }
    }

    public static void main(String[] args) {
        ZoneId madrid = ZoneId.of("Europe/Madrid");
        Clock reloj = Clock.fixed(Instant.parse("2026-10-05T07:00:00Z"), madrid); // lunes 5/10 a las 9:00
        Agenda agenda = new Agenda(reloj);
        Locale es = Locale.of("es", "ES");
        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("EEEE d 'de' MMMM, HH:mm", es);

        LocalDate martes = LocalDate.of(2026, 10, 6);
        agenda.reservar("Ana Ruiz", martes.atTime(9, 0), Duration.ofMinutes(30));
        agenda.reservar("Luis Gil", martes.atTime(10, 0), Duration.ofMinutes(60));
        agenda.reservar("Marta Sanz", LocalDateTime.of(2026, 10, 8, 12, 30), Duration.ofMinutes(45));

        String[][] intentos = {
                {"Pedro", "2026-10-06T10:30", "30"},   // se solapa con Luis
                {"Eva", "2026-10-10T10:00", "30"},     // sábado
                {"Raúl", "2026-10-06T13:45", "30"},    // termina a las 14:15
                {"Sara", "2026-10-02T10:00", "30"},    // en el pasado
                {"Irene", "2026-10-06T11:00", "30"}    // correcta
        };
        for (String[] i : intentos) {
            try {
                agenda.reservar(i[0], LocalDateTime.parse(i[1]), Duration.ofMinutes(Long.parseLong(i[2])));
                System.out.println("Reservada la cita de " + i[0]);
            } catch (IllegalArgumentException e) {
                System.out.println("Rechazada la cita de " + i[0] + ": " + e.getMessage());
            }
        }

        System.out.println("\nCitas de la semana:");
        for (Cita c : agenda.citasDeLaSemana(martes)) {
            System.out.printf("  %-30s %-11s (%d min)%n", c.inicio().format(fmt), c.paciente(), c.duracion().toMinutes());
        }
        System.out.println("\nHuecos libres el martes: " + agenda.huecosLibres(martes));

        // Recordatorio 24 h antes, en la zona horaria del paciente (que vive en Canarias)
        Cita c = agenda.citasDeLaSemana(martes).get(0);
        ZonedDateTime recordatorio = c.inicio().atZone(madrid).minusHours(24)
                .withZoneSameInstant(ZoneId.of("Atlantic/Canary"));
        System.out.println("\nSMS a " + c.paciente() + ": " + recordatorio.format(fmt) + " (hora canaria)");
    }
}
Rechazada la cita de Pedro: Se solapa con la cita de Luis Gil
Rechazada la cita de Eva: La consulta cierra los fines de semana
Rechazada la cita de Raúl: Fuera del horario de 9:00 a 14:00
Rechazada la cita de Sara: No se puede reservar en el pasado: 2026-10-02T10:00
Reservada la cita de Irene

Citas de la semana:
  martes 6 de octubre, 09:00     Ana Ruiz    (30 min)
  martes 6 de octubre, 10:00     Luis Gil    (60 min)
  martes 6 de octubre, 11:00     Irene       (30 min)
  jueves 8 de octubre, 12:30     Marta Sanz  (45 min)

Huecos libres el martes: [09:30, 11:30, 12:00, 12:30, 13:00, 13:30]

SMS a Ana Ruiz: lunes 5 de octubre, 08:00 (hora canaria)

Claves del diseño:

  • La condición de solapamiento de dos intervalos semiabiertos [a1, b1) y [a2, b2) es a1 < b2 && a2 < b1. Memorízala: aparece en reservas de hotel, alquiler de coches, aulas, etc.
  • La agenda recibe un Clock, así que “el pasado” es reproducible: siempre es el lunes 5 a las 9:00.
  • Las citas se guardan como LocalDateTime porque la consulta está en un único lugar; el recordatorio, que cruza zonas, pasa a ZonedDateTime.

14.2. Nóminas prorrateadas por días

Una empresa paga el salario mensual completo a quien trabaja todo el mes y la parte proporcional a los días naturales trabajados a quien entra o sale a mitad de mes. Además, suma 35 € por cada trienio de antigüedad.

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.Period;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.time.format.TextStyle;
import java.util.List;
import java.util.Locale;

public class Nominas {

    record Empleado(String nombre, BigDecimal salarioMensual, LocalDate alta, LocalDate baja) { }

    static final Locale ES = Locale.of("es", "ES");
    static final BigDecimal PLUS_TRIENIO = new BigDecimal("35.00");

    public static void main(String[] args) {
        YearMonth mes = YearMonth.of(2026, 10);
        List<Empleado> plantilla = List.of(
                new Empleado("Laura Martín", new BigDecimal("2100.00"), LocalDate.of(2017, 3, 1), null),
                new Empleado("Jorge Pérez", new BigDecimal("1800.00"), LocalDate.of(2026, 10, 14), null),
                new Empleado("Nuria Vidal", new BigDecimal("1950.00"), LocalDate.of(2022, 9, 5), LocalDate.of(2026, 10, 20)));

        NumberFormat euros = NumberFormat.getCurrencyInstance(ES);
        DateTimeFormatter f = DateTimeFormatter.ofPattern("dd/MM/yyyy");
        String nombreMes = mes.getMonth().getDisplayName(TextStyle.FULL, ES);
        System.out.println("NÓMINAS DE " + nombreMes.toUpperCase(ES) + " DE " + mes.getYear()
                + " (" + mes.lengthOfMonth() + " días naturales, " + diasLaborables(mes.atDay(1), mes.atEndOfMonth()) + " laborables)");
        System.out.println("-".repeat(78));

        for (Empleado e : plantilla) {
            // Periodo trabajado dentro del mes: [max(alta, día 1), min(baja, último día)]
            LocalDate desde = e.alta().isAfter(mes.atDay(1)) ? e.alta() : mes.atDay(1);
            LocalDate hasta = e.baja() != null && e.baja().isBefore(mes.atEndOfMonth()) ? e.baja() : mes.atEndOfMonth();
            long dias = desde.datesUntil(hasta.plusDays(1)).count();

            BigDecimal salarioDia = e.salarioMensual().divide(BigDecimal.valueOf(mes.lengthOfMonth()), 6, RoundingMode.HALF_UP);
            BigDecimal base = salarioDia.multiply(BigDecimal.valueOf(dias)).setScale(2, RoundingMode.HALF_UP);

            int anios = Period.between(e.alta(), hasta).getYears();
            int trienios = anios / 3;
            BigDecimal antiguedad = PLUS_TRIENIO.multiply(BigDecimal.valueOf(trienios));
            BigDecimal total = base.add(antiguedad);

            System.out.printf("%-13s del %s al %s: %2d días -> base %11s + %d trienio(s) %9s = %11s%n",
                    e.nombre(), desde.format(f), hasta.format(f), dias,
                    euros.format(base), trienios, euros.format(antiguedad), euros.format(total));
        }
    }

    static long diasLaborables(LocalDate inicio, LocalDate finIncluido) {
        return inicio.datesUntil(finIncluido.plusDays(1))
                .filter(d -> d.getDayOfWeek() != DayOfWeek.SATURDAY && d.getDayOfWeek() != DayOfWeek.SUNDAY)
                .count();
    }
}
NÓMINAS DE OCTUBRE DE 2026 (31 días naturales, 22 laborables)
------------------------------------------------------------------------------
Laura Martín  del 01/10/2026 al 31/10/2026: 31 días -> base  2.100,00 € + 3 trienio(s)  105,00 € =  2.205,00 €
Jorge Pérez   del 14/10/2026 al 31/10/2026: 18 días -> base  1.045,16 € + 0 trienio(s)    0,00 € =  1.045,16 €
Nuria Vidal   del 01/10/2026 al 20/10/2026: 20 días -> base  1.258,06 € + 1 trienio(s)   35,00 € =  1.293,06 €

Comprueba los cálculos: Jorge cobra 1800 / 31 × 18 = 1045,16 €. Laura entró en marzo de 2017: a 31/10/2026 lleva 9 años, es decir, 3 trienios. YearMonth nos da la longitud del mes (lengthOfMonth()), su primer día (atDay(1)) y el último (atEndOfMonth()) sin pensar en bisiestos.

⚠️ Importante: Con double, 1800.0 / 31 * 18 da 1045.1612903225805 y los redondeos acumulados en miles de nóminas producen céntimos de diferencia que ningún auditor acepta. Con BigDecimal decides la escala y el modo de redondeo (RoundingMode.HALF_UP, el redondeo “escolar”).

14.3. Cuenta atrás

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;

public class CuentaAtras {

    static String formatear(Duration d) {
        if (d.isNegative() || d.isZero()) {
            return "¡Ya ha llegado!";
        }
        // Las partes toXxxPart() existen desde Java 9
        return String.format("%d días, %02d h %02d min %02d s",
                d.toDaysPart(), d.toHoursPart(), d.toMinutesPart(), d.toSecondsPart());
    }

    static String faltaPara(ZonedDateTime evento, Clock reloj) {
        return formatear(Duration.between(ZonedDateTime.now(reloj), evento));
    }

    public static void main(String[] args) throws InterruptedException {
        ZoneId madrid = ZoneId.of("Europe/Madrid");
        ZonedDateTime finDeCurso = ZonedDateTime.of(2027, 6, 22, 14, 0, 0, 0, madrid);
        ZonedDateTime nochevieja = ZonedDateTime.of(2027, 1, 1, 0, 0, 0, 0, madrid);

        // Con un reloj fijo la salida es siempre la misma (ideal para pruebas)
        Clock fijo = Clock.fixed(Instant.parse("2026-10-04T10:15:30Z"), madrid);
        System.out.println("Desde " + ZonedDateTime.now(fijo));
        System.out.println("  Fin de curso: " + faltaPara(finDeCurso, fijo));
        System.out.println("  Año nuevo:    " + faltaPara(nochevieja, fijo));
        System.out.println("  Evento pasado: " + faltaPara(ZonedDateTime.of(2026, 1, 1, 0, 0, 0, 0, madrid), fijo));

        // Cuenta atrás "en vivo" de 3 segundos con el reloj real
        Clock real = Clock.system(madrid);
        ZonedDateTime despegue = ZonedDateTime.now(real).plusSeconds(3);
        while (true) {
            Duration resto = Duration.between(ZonedDateTime.now(real), despegue);
            if (resto.isNegative() || resto.isZero()) {
                System.out.println("¡Despegue!");
                break;
            }
            // redondeamos hacia arriba los segundos que faltan
            System.out.println("T-" + (resto.toMillis() + 999) / 1000);
            Thread.sleep(Math.min(1000, resto.toMillis()));
        }
    }
}
Desde 2026-10-04T12:15:30+02:00[Europe/Madrid]
  Fin de curso: 261 días, 01 h 44 min 30 s
  Año nuevo:    88 días, 12 h 44 min 30 s
  Evento pasado: ¡Ya ha llegado!
T-3
T-2
T-1
¡Despegue!

Entre el 4 de octubre y el 22 de junio hay dos cambios de hora (25 de octubre y 28 de marzo) que se compensan, por eso el resultado coincide con la diferencia de horas de pared. Si el evento estuviera entre ambos (por ejemplo, Año nuevo), Duration cuenta las horas reales: de 12:15:30 a 00:00 hay 11 h 44 min 30 s de pared, pero el reloj se atrasa una hora en medio, y por eso salen 12 h 44 min 30 s.


15. Buenas prácticas y errores comunes

Error Por qué está mal Solución
fecha.plusDays(1); sin asignar Los objetos son inmutables: el resultado se pierde fecha = fecha.plusDays(1);
Usar LocalDateTime para instantes globales No sabe a qué zona pertenece; ambiguo entre servidores Instant, OffsetDateTime o ZonedDateTime
ZoneId.of("CET") o "EST" Abreviaturas ambiguas ZoneId.of("Europe/Madrid")
LocalDate.now() en clases de negocio Imposible de probar Inyectar un Clock
Patrón YYYY o DD Y es año “de la semana” y D día del año uuuu/yyyy y dd
Formatear sin Locale Depende del sistema donde se ejecute ofPattern(p, Locale.of("es", "ES"))
Patrón con zona (O, XXX, z) sobre un LocalDateTime No tiene zona: UnsupportedTemporalTypeException: Unsupported field: OffsetSeconds Formatear un ZonedDateTime/OffsetDateTime
Validar con el resolver por defecto Acepta el 31/04 y lo convierte en 30/04 ResolverStyle.STRICT con uuuu
Calcular edad restando años Falla si aún no ha cumplido Period.between(...).getYears()
Dinero en double Errores de redondeo binario BigDecimal + RoundingMode
Suponer que un día tiene 24 h Los días de cambio de hora tienen 23 o 25 plusDays para calendario, Duration para tiempo real

16. Ejercicios

Ejercicio 1: Días hasta tu cumpleaños

Escribe un método long diasHastaCumple(LocalDate nacimiento, LocalDate hoy) que devuelva cuántos días faltan para el próximo cumpleaños (0 si es hoy). Ten en cuenta a quienes nacieron un 29 de febrero. Prueba con hoy = 04/10/2026 y nacimientos 04/10/2000, 05/10/1999, 03/10/2004 y 29/02/2004.

Ver solución
import java.time.LocalDate;
import java.time.MonthDay;
import java.time.temporal.ChronoUnit;

public class Ej1Cumple {

    static long diasHastaCumple(LocalDate nacimiento, LocalDate hoy) {
        MonthDay md = MonthDay.from(nacimiento);
        // atYear ajusta el 29 de febrero al 28 en los años no bisiestos
        LocalDate proximo = md.atYear(hoy.getYear());
        if (proximo.isBefore(hoy)) {
            proximo = md.atYear(hoy.getYear() + 1);
        }
        return ChronoUnit.DAYS.between(hoy, proximo);
    }

    public static void main(String[] args) {
        LocalDate hoy = LocalDate.of(2026, 10, 4);
        LocalDate[] fechas = {
                LocalDate.of(2000, 10, 4), LocalDate.of(1999, 10, 5),
                LocalDate.of(2004, 10, 3), LocalDate.of(2004, 2, 29)};
        for (LocalDate n : fechas) {
            long d = diasHastaCumple(n, hoy);
            System.out.println("Nacido el " + n + ": " + (d == 0 ? "¡Feliz cumpleaños!" : d == 1 ? "falta 1 día" : "faltan " + d + " días"));
        }
    }
}
Nacido el 2000-10-04: ¡Feliz cumpleaños!
Nacido el 1999-10-05: falta 1 día
Nacido el 2004-10-03: faltan 364 días
Nacido el 2004-02-29: faltan 147 días

MonthDay representa justo lo que necesitamos: el día y el mes sin año. atYear resuelve el caso del 29 de febrero ajustándolo al 28 en años no bisiestos (en 2027, el 28 de febrero, que está a 147 días). Recibir hoy como parámetro (en lugar de llamar a LocalDate.now()) hace que el método sea fácil de probar.

Ejercicio 2: Validador de fechas introducidas por teclado

Lee líneas por la entrada estándar y, para cada una, indica si es una fecha válida en formato dd/mm/aaaa (rechazando fechas imposibles como el 31/04) y qué día de la semana es. Implementa un método Optional<LocalDate> parsear(String) que ignore espacios alrededor.

Ver solución
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.Optional;
import java.util.Scanner;

public class Ej2Validar {

    private static final DateTimeFormatter FORMATO =
            DateTimeFormatter.ofPattern("dd/MM/uuuu").withResolverStyle(ResolverStyle.STRICT);

    static Optional<LocalDate> parsear(String texto) {
        if (texto == null) {
            return Optional.empty();
        }
        try {
            return Optional.of(LocalDate.parse(texto.strip(), FORMATO));
        } catch (DateTimeParseException e) {
            return Optional.empty();
        }
    }

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        while (sc.hasNextLine()) {
            String linea = sc.nextLine();
            System.out.println("Fecha (dd/mm/aaaa): " + linea);
            parsear(linea).ifPresentOrElse(
                    f -> System.out.println("  Correcta: " + f + " (" + f.getDayOfWeek() + ")"),
                    () -> System.out.println("  Fecha no válida"));
        }
    }
}

Ejecución real pasando la entrada por una tubería:

printf "25/12/2026\n31/04/2026\n29/02/2028\n2026-10-04\n 07/07/2027 \n" | docker run --rm -i -v "$PWD:/w" -w /w maven:3.9-eclipse-temurin-25 java Ej2Validar.java
Fecha (dd/mm/aaaa): 25/12/2026
  Correcta: 2026-12-25 (FRIDAY)
Fecha (dd/mm/aaaa): 31/04/2026
  Fecha no válida
Fecha (dd/mm/aaaa): 29/02/2028
  Correcta: 2028-02-29 (TUESDAY)
Fecha (dd/mm/aaaa): 2026-10-04
  Fecha no válida
Fecha (dd/mm/aaaa):  07/07/2027 
  Correcta: 2027-07-07 (WEDNESDAY)

La combinación uuuu + ResolverStyle.STRICT rechaza el 31 de abril y acepta el 29 de febrero solo en años bisiestos. Devolver Optional en lugar de lanzar la excepción hace explícito que el resultado puede no existir (módulo 03).

Ejercicio 3: Calendario mensual en consola

Escribe un método imprimir(YearMonth ym, Locale locale) que muestre un calendario como el del comando cal de Linux, con la semana empezando en lunes y los nombres en español. Pruébalo con octubre de 2026 y febrero de 2027.

Ver solución
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.TextStyle;
import java.util.Locale;

public class Ej3Calendario {

    static void imprimir(YearMonth ym, Locale locale) {
        String titulo = ym.getMonth().getDisplayName(TextStyle.FULL_STANDALONE, locale) + " " + ym.getYear();
        int margen = (20 - titulo.length()) / 2;
        System.out.println(" ".repeat(Math.max(0, margen)) + titulo);
        for (DayOfWeek d : DayOfWeek.values()) {             // MONDAY ... SUNDAY
            System.out.print(d.getDisplayName(TextStyle.SHORT, locale).substring(0, 2) + " ");
        }
        System.out.println();
        LocalDate primero = ym.atDay(1);
        int hueco = primero.getDayOfWeek().getValue() - 1;  // lunes = 1
        System.out.print("   ".repeat(hueco));
        for (int dia = 1; dia <= ym.lengthOfMonth(); dia++) {
            System.out.printf("%2d ", dia);
            if (ym.atDay(dia).getDayOfWeek() == DayOfWeek.SUNDAY || dia == ym.lengthOfMonth()) {
                System.out.println();
            }
        }
    }

    public static void main(String[] args) {
        imprimir(YearMonth.of(2026, 10), Locale.of("es", "ES"));
        imprimir(YearMonth.of(2027, 2), Locale.of("es", "ES"));
    }
}
    octubre 2026
lu ma mi ju vi sá do 
          1  2  3  4 
 5  6  7  8  9 10 11 
12 13 14 15 16 17 18 
19 20 21 22 23 24 25 
26 27 28 29 30 31 
    febrero 2027
lu ma mi ju vi sá do 
 1  2  3  4  5  6  7 
 8  9 10 11 12 13 14 
15 16 17 18 19 20 21 
22 23 24 25 26 27 28 

DayOfWeek.values() empieza en lunes (ISO-8601), y getValue() devuelve 1 para el lunes, lo que permite calcular cuántos huecos dejar antes del día 1. TextStyle.FULL_STANDALONE da el nombre del mes “suelto”, sin declinación (en español coincide con FULL, pero en idiomas como el ruso o el polaco no).

Ejercicio 4: Convocar una videollamada internacional

Una empresa tiene oficinas en Madrid, Las Palmas, Londres, Bogotá, Nueva York y Tokio. Dada una reunión el martes 27/10/2026 a las 16:00 de Madrid, muestra la hora local de cada oficina (con día de la semana y desfase) y marca las que quedan fuera del horario laboral (lunes a viernes de 8:00 a 18:00).

Ver solución
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Map;
import java.util.LinkedHashMap;

public class Ej4Videollamada {

    static boolean esHorarioLaboral(ZonedDateTime z) {
        LocalTime h = z.toLocalTime();
        int dia = z.getDayOfWeek().getValue();
        return dia <= 5 && !h.isBefore(LocalTime.of(8, 0)) && h.isBefore(LocalTime.of(18, 0));
    }

    public static void main(String[] args) {
        Map<String, ZoneId> oficinas = new LinkedHashMap<>();
        oficinas.put("Madrid", ZoneId.of("Europe/Madrid"));
        oficinas.put("Las Palmas", ZoneId.of("Atlantic/Canary"));
        oficinas.put("Londres", ZoneId.of("Europe/London"));
        oficinas.put("Bogotá", ZoneId.of("America/Bogota"));
        oficinas.put("Nueva York", ZoneId.of("America/New_York"));
        oficinas.put("Tokio", ZoneId.of("Asia/Tokyo"));

        ZonedDateTime reunion = ZonedDateTime.of(2026, 10, 27, 16, 0, 0, 0, oficinas.get("Madrid"));
        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("EEE dd/MM HH:mm (xxx)", Locale.of("es", "ES"));

        System.out.println("Videollamada convocada para " + reunion.format(fmt) + " en Madrid");
        oficinas.forEach((ciudad, zona) -> {
            ZonedDateTime local = reunion.withZoneSameInstant(zona);
            System.out.printf("  %-11s %s %s%n", ciudad, local.format(fmt),
                    esHorarioLaboral(local) ? "" : "<- fuera de horario");
        });
    }
}
Videollamada convocada para mar 27/10 16:00 (+01:00) en Madrid
  Madrid      mar 27/10 16:00 (+01:00) 
  Las Palmas  mar 27/10 15:00 (+00:00) 
  Londres     mar 27/10 15:00 (+00:00) 
  Bogotá      mar 27/10 10:00 (-05:00) 
  Nueva York  mar 27/10 11:00 (-04:00) 
  Tokio       mié 28/10 00:00 (+09:00) <- fuera de horario

Fíjate en un detalle real: el 27 de octubre Europa ya ha vuelto al horario de invierno (25 de octubre), pero Estados Unidos no lo hace hasta el 1 de noviembre. Por eso esa semana Nueva York está a solo 5 horas de Madrid en lugar de las 6 habituales. Calcularlo a mano con desfases fijos habría dado un error; con ZoneId las reglas se aplican solas.

Ejercicio 5: Control de fichajes semanal

Dada una lista de fichajes de una semana (entrada y salida con fecha y hora, en formato dd/MM/yyyy HH:mm), muestra la duración de cada jornada, el total semanal y las horas extra sobre 40 h (o las que faltan). Incluye un turno de noche que empieza el viernes a las 22:00 y termina el sábado a las 06:00.

Ver solución
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.TextStyle;
import java.util.List;
import java.util.Locale;

public class Ej5Fichajes {

    record Fichaje(LocalDateTime entrada, LocalDateTime salida) {
        Duration duracion() {
            return Duration.between(entrada, salida);
        }
    }

    static String hm(Duration d) {
        return String.format("%dh %02dmin", d.toHours(), d.toMinutesPart());
    }

    public static void main(String[] args) {
        DateTimeFormatter f = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
        List<Fichaje> semana = List.of(
                new Fichaje(LocalDateTime.parse("05/10/2026 07:55", f), LocalDateTime.parse("05/10/2026 16:10", f)),
                new Fichaje(LocalDateTime.parse("06/10/2026 08:02", f), LocalDateTime.parse("06/10/2026 17:30", f)),
                new Fichaje(LocalDateTime.parse("07/10/2026 08:00", f), LocalDateTime.parse("07/10/2026 15:00", f)),
                new Fichaje(LocalDateTime.parse("08/10/2026 07:45", f), LocalDateTime.parse("08/10/2026 18:15", f)),
                new Fichaje(LocalDateTime.parse("09/10/2026 22:00", f), LocalDateTime.parse("10/10/2026 06:00", f)));

        Locale es = Locale.of("es", "ES");
        Duration total = Duration.ZERO;
        for (Fichaje fi : semana) {
            total = total.plus(fi.duracion());
            System.out.printf("%-10s %s -> %s  %s%n",
                    fi.entrada().getDayOfWeek().getDisplayName(TextStyle.FULL, es),
                    fi.entrada().toLocalTime(), fi.salida().toLocalTime(), hm(fi.duracion()));
        }
        Duration jornada = Duration.ofHours(40);
        Duration extra = total.minus(jornada);
        System.out.println("Total semanal: " + hm(total));
        System.out.println(extra.isNegative() ? "Faltan " + hm(extra.negated()) : "Horas extra: " + hm(extra));
    }
}
lunes      07:55 -> 16:10  8h 15min
martes     08:02 -> 17:30  9h 28min
miércoles  08:00 -> 15:00  7h 00min
jueves     07:45 -> 18:15  10h 30min
viernes    22:00 -> 06:00  8h 00min
Total semanal: 43h 13min
Horas extra: 3h 13min

Como guardamos LocalDateTime (y no solo LocalTime), el turno de noche se calcula bien: 8 horas. Con dos LocalTime (22:00 y 06:00) Duration.between habría devuelto −16 horas. Duration es inmutable, por eso acumulamos con total = total.plus(...).

Ejercicio 6: Vencimiento de facturas

Una empresa ofrece dos condiciones de pago: “a 30 días naturales” y “al final del mes siguiente”. En ambos casos, si el vencimiento cae en sábado, domingo o festivo, se traslada al siguiente día hábil. Implementa un TemporalAdjuster reutilizable HABIL_O_SIGUIENTE y calcula ambos vencimientos para facturas del 03/10/2026, 08/11/2026, 25/11/2026 y 02/12/2026 (festivos: 02/11, 08/12 y 25/12 de 2026; 01/01 y 06/01 de 2027).

Ver solución
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjuster;
import java.time.temporal.TemporalAdjusters;
import java.util.Set;

public class Ej6Vencimiento {

    static final Set<LocalDate> FESTIVOS = Set.of(
            LocalDate.of(2026, 11, 2), LocalDate.of(2026, 12, 8), LocalDate.of(2026, 12, 25),
            LocalDate.of(2027, 1, 1), LocalDate.of(2027, 1, 6));

    static boolean esHabil(LocalDate f) {
        return f.getDayOfWeek() != DayOfWeek.SATURDAY && f.getDayOfWeek() != DayOfWeek.SUNDAY
                && !FESTIVOS.contains(f);
    }

    // Si la fecha no es hábil, avanza hasta el siguiente día hábil
    static final TemporalAdjuster HABIL_O_SIGUIENTE = TemporalAdjusters.ofDateAdjuster(f -> {
        LocalDate d = f;
        while (!esHabil(d)) {
            d = d.plusDays(1);
        }
        return d;
    });

    static LocalDate vencimiento30Dias(LocalDate factura) {
        return factura.plusDays(30).with(HABIL_O_SIGUIENTE);
    }

    static LocalDate vencimientoFinMesSiguiente(LocalDate factura) {
        return factura.plusMonths(1).with(TemporalAdjusters.lastDayOfMonth()).with(HABIL_O_SIGUIENTE);
    }

    public static void main(String[] args) {
        LocalDate[] facturas = {LocalDate.of(2026, 10, 3), LocalDate.of(2026, 11, 8),
                LocalDate.of(2026, 11, 25), LocalDate.of(2026, 12, 2)};
        System.out.println("Factura     | 30 días    | Fin mes siguiente");
        for (LocalDate f : facturas) {
            System.out.println(f + "  | " + vencimiento30Dias(f) + " | " + vencimientoFinMesSiguiente(f));
        }
    }
}
Factura     | 30 días    | Fin mes siguiente
2026-10-03  | 2026-11-03 | 2026-11-30
2026-11-08  | 2026-12-09 | 2026-12-31
2026-11-25  | 2026-12-28 | 2026-12-31
2026-12-02  | 2027-01-04 | 2027-02-01

Revisa la última fila: 02/12/2026 + 30 días = 01/01/2027 (festivo, viernes) → sábado → domingo → lunes 4. Y el fin del mes siguiente, el 31/01/2027, es domingo, así que pasa al 1 de febrero. Como el ajustador es un objeto, se compone con otros: plusMonths(1).with(lastDayOfMonth()).with(HABIL_O_SIGUIENTE).

Ejercicio 7 (propuesto): Edad exacta

Escribe un programa que, dada una fecha de nacimiento leída por teclado, muestre la edad exacta en formato “20 años, 11 meses y 29 días”, cuántos días ha vivido la persona y qué día de la semana nació (en español). Usa un Clock para que el programa sea comprobable.

Ejercicio 8 (propuesto): Semana Santa

Implementa el algoritmo de Butcher-Meeus (búscalo: calcula el domingo de Pascua de cualquier año) y, a partir de él, obtén el Jueves y el Viernes Santo y el Lunes de Pascua de 2026 a 2030. Añádelos al conjunto de festivos del ejemplo de días hábiles.

Ejercicio 9 (propuesto): Parser de duraciones humanas

Crea un método Duration parsearDuracion(String texto) que acepte textos como "1h 30m", "45m", "2h" o "1h 5m 30s" (en cualquier orden y con espacios opcionales) y lance IllegalArgumentException si el formato no es válido. Pista: una expresión regular con grupos y Duration.ofHours(...).plusMinutes(...).

Ejercicio 10 (propuesto): Reloj mundial

Muestra una tabla con la hora actual en 8 ciudades (elige tú las zonas IANA), su desfase respecto a UTC, si están en horario de verano (ZoneRules.isDaylightSavings(Instant)) y la fecha del próximo cambio de hora (nextTransition). Ordena la tabla por desfase.

Ejercicio 11 (propuesto): Reservas de un hotel

Modela una reserva con fecha de entrada y de salida (LocalDate, intervalo semiabierto: la noche de la salida no se cuenta). Implementa: número de noches, precio total con tarifa distinta en fin de semana (viernes y sábado por la noche), detección de solapamientos para una misma habitación y listado de ocupación de un mes con datesUntil.

Ejercicio 12 (propuesto): Aplicación bilingüe

Crea los ficheros textos.properties, textos_en.properties y textos_ca.properties con los mensajes de un pequeño menú (bienvenida, opciones, despedida). Pide al usuario el idioma al arrancar y muestra el menú, la fecha de hoy con FormatStyle.FULL y el saldo de una cuenta con NumberFormat.getCurrencyInstance en el Locale elegido.


Resumen

Concepto Clase / método Ejemplo
Fecha sin hora LocalDate LocalDate.of(2026, 10, 4)
Hora sin fecha LocalTime LocalTime.of(18, 30)
Fecha y hora local LocalDateTime fecha.atTime(10, 30)
Momento universal Instant Instant.now(), toEpochMilli()
Fecha y hora con zona ZonedDateTime ldt.atZone(ZoneId.of("Europe/Madrid"))
Fecha y hora con desfase OffsetDateTime zdt.toOffsetDateTime()
Cambiar de zona withZoneSameInstant zdt.withZoneSameInstant(canarias)
Tiempo de cronómetro Duration Duration.between(t0, t1).toMinutes()
Tiempo de calendario Period Period.between(nac, hoy).getYears()
Unidades completas ChronoUnit ChronoUnit.DAYS.between(a, b)
Rango de fechas datesUntil inicio.datesUntil(fin).count()
Ajustadores TemporalAdjusters hoy.with(firstInMonth(MONDAY))
Formatear DateTimeFormatter ofPattern("dd/MM/uuuu", es)
Validar estricto ResolverStyle.STRICT .withResolverStyle(ResolverStyle.STRICT)
Error de parseo DateTimeParseException try { LocalDate.parse(...) } catch (...)
Conversión heredada toInstant(), Date.from() Timestamp.valueOf(ldt)
Reloj para pruebas Clock Clock.fixed(instant, zona)
Idioma y región Locale Locale.of("es", "ES")
Moneda y porcentajes NumberFormat getCurrencyInstance(es).format(precio)
Textos traducibles ResourceBundle ResourceBundle.getBundle("mensajes", l)