Al cerrar la lección anterior quedó señalada una deuda que arrastras desde el módulo 3: las fechas de BiblioTech son enteros.

Prestamo guarda un int diaPrestamo y un int diaVencimiento. CalculadoraMultas resta esos dos enteros y llama al resultado "días de retraso". El histograma del panel calcula el día de la semana con diaPrestamo % 7, que funciona por pura casualidad. Nadie puede responder a "¿este préstamo vence dentro de un mes?" sin decidir arbitrariamente si un mes son treinta días o treinta y uno. El CSV exporta números que fuera de BiblioTech no significan absolutamente nada. Y las marcas de tiempo de los ficheros, que en 07-06 mostraste como FileTime sin poder hacer nada con ellas, siguen ahí esperando.

Todo eso se acaba en esta lección.

Y conviene saber por qué llegó tan tarde: durante casi veinte años, Java no tuvo una API de fechas decente. java.util.Date (1996) y java.util.Calendar (1997) fueron dos de los errores de diseño más famosos de la biblioteca estándar, hasta el punto de que la práctica recomendada en el mundo empresarial era no usarlas y recurrir a una biblioteca externa, Joda-Time. En 2014, Java 8 incorporó java.time, diseñada por el propio autor de Joda-Time (Stephen Colebourne) sobre las lecciones aprendidas, y estandarizada como JSR-310.

El resultado es una API que hace bien lo que la anterior hacía mal: es inmutable, segura entre hilos, fluida y explícita sobre si hay zona horaria o no. Merece la pena aprenderla con calma, porque las fechas son el dominio donde más bugs sutiles se cometen: horarios de verano, años bisiestos, zonas horarias, meses de duración variable y usuarios en distintos husos.

Al terminar, Prestamo tendrá LocalDate fechaPrestamo y LocalDate fechaVencimiento, las multas se calcularán con ChronoUnit.DAYS.between, los avisos caerán en día hábil gracias a TemporalAdjusters, y el CSV llevará fechas ISO-8601 que cualquier sistema del mundo entiende.

Contenido

  1. Por qué existió el problema: Date y Calendar
  2. El bug clásico: SimpleDateFormat compartido
  3. Los principios de diseño de java.time
  4. Las clases núcleo y cómo elegir
  5. Instant frente a LocalDateTime: la regla de oro
  6. Creación: now, of, parse
  7. Consulta: obtener partes y propiedades
  8. Manipulación: métodos que devuelven copias
  9. Comparación
  10. Duration frente a Period
  11. ChronoUnit.between
  12. Ajustadores temporales: TemporalAdjusters
  13. Zonas horarias: ZoneId y ZoneOffset
  14. El horario de verano y sus dos casos peligrosos
  15. Formateo y parseo con DateTimeFormatter
  16. Locale: nombres de mes y día en español
  17. Interoperabilidad con la API antigua y con FileTime
  18. Clock: la fuente de tiempo inyectable
  19. BiblioTech: la migración completa
  20. Errores Comunes y Consejos
  21. Ejercicios

  1. Por qué existió el problema: Date y Calendar

Para apreciar java.time conviene ver de qué se escapa. Este código usa la API antigua:

import java.util.Calendar;
import java.util.Date;

public class ApiAntigua {

    public static void main(String[] args) {

        // Crear el 15 de marzo de 2026
        Calendar c = Calendar.getInstance();
        c.set(2026, 2, 15);          // ¡2 es MARZO! Los meses empiezan en 0
        Date fecha = c.getTime();

        System.out.println(fecha);

        // Sumar 30 dias: MUTA el objeto
        c.add(Calendar.DAY_OF_MONTH, 30);
        System.out.println(c.getTime());

        // Y el objeto original ha cambiado tambien, porque es el mismo
        Date otraReferencia = fecha;
        System.out.println(otraReferencia);
    }
}

Los defectos, uno por uno:

Defecto Consecuencia
Meses desde 0 c.set(2026, 2, 15) es marzo, no febrero. Error de un mes en producción
Años desde 1900 en Date new Date(126, 2, 15) es 2026. Absurdo
Mutables Pasar un Date a un método puede devolverlo modificado
No seguras entre hilos Calendar y SimpleDateFormat compartidos corrompen datos
Sin separación de conceptos Un Date no es una fecha: es un instante. No hay forma de expresar "el 15 de marzo" sin hora ni zona
API confusa Date tiene métodos obsoletos desde Java 1.1 que siguen ahí
Date no representa una fecha Internamente es un long de milisegundos desde 1970 UTC
Aritmética pobre Sumar un mes requiere Calendar, y el resultado depende del estado del objeto

Comparación directa:

Tarea API antigua java.time
Hoy new Date() LocalDate.now()
15 de marzo de 2026 cal.set(2026, 2, 15) LocalDate.of(2026, 3, 15)
Sumar 30 días cal.add(Calendar.DAY_OF_MONTH, 30) (muta) fecha.plusDays(30) (devuelve copia)
¿Es anterior? d1.before(d2) f1.isBefore(f2)
Días entre dos fechas Aritmética manual con milisegundos ChronoUnit.DAYS.between(f1, f2)
Formatear new SimpleDateFormat("dd/MM/yyyy") (inseguro) DateTimeFormatter.ofPattern("dd/MM/yyyy") (seguro)
Solo la hora Imposible sin trampas LocalTime.of(9, 30)
Solo el mes y el año Imposible YearMonth.of(2026, 3)

  1. El bug clásico: SimpleDateFormat compartido

Este merece su propio apartado porque es, literalmente, uno de los bugs más frecuentes de la historia de Java empresarial.

package com.nexussoftware.bibliotech;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.*;

public class BugDeSimpleDateFormat {

    // Parece razonable: un formateador reutilizable, para no crear uno por llamada
    private static final SimpleDateFormat FORMATO = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss");

    public static void main(String[] args) throws Exception {

        ExecutorService pool = Executors.newFixedThreadPool(10);
        Date fecha = new Date();

        for (int i = 0; i < 20; i++) {
            pool.submit(() -> {
                try {
                    System.out.println(Thread.currentThread().getName()
                            + " -> " + FORMATO.format(fecha));
                } catch (Exception e) {
                    System.out.println("EXCEPCIÓN: " + e);
                }
            });
        }
        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);
    }
}

Salida (variable en cada ejecución):

pool-1-thread-1 -> 05/08/2026 14:32:07
pool-1-thread-3 -> 05/08/2026 14:32:07
pool-1-thread-2 -> 05/08/2026 04:32:07     <-- hora incorrecta
pool-1-thread-5 -> 05/08/2020 14:32:07     <-- año incorrecto
EXCEPCIÓN: java.lang.NumberFormatException: multiple points
pool-1-thread-4 -> 05/08/2026 14:32:07

Por qué ocurre: SimpleDateFormat guarda estado interno mutable (un Calendar y un buffer) mientras formatea. Si dos hilos formatean a la vez, se pisan ese estado. El resultado no es una excepción clara sino datos silenciosamente incorrectos, que es infinitamente peor.

Y es un bug traicionero porque:

  • No falla en desarrollo, donde un solo hilo formatea cada vez.
  • Falla bajo carga, en producción, de forma intermitente.
  • Produce datos malos, no errores: una fecha con el año equivocado se guarda en la base de datos y nadie se entera.
  • Parece una buena práctica. Declarar el formateador como static final es exactamente lo que uno haría para no crear objetos innecesarios.

La solución con la API antigua era crear uno por llamada (desperdiciando memoria) o usar ThreadLocal (que introduce otro problema, como verás en 10-07). Con java.time:

// DateTimeFormatter es INMUTABLE y SEGURO entre hilos.
// Compartirlo como constante static final es lo CORRECTO.
private static final DateTimeFormatter FORMATO =
        DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss");

Sin ningún riesgo, con cualquier número de hilos.

  1. Los principios de diseño de java.time

Cuatro decisiones explican toda la API:

1. Inmutabilidad. Ningún objeto de java.time cambia jamás. Todos los métodos "de modificación" devuelven un objeto nuevo:

LocalDate hoy = LocalDate.of(2026, 8, 5);
LocalDate futuro = hoy.plusDays(30);

System.out.println(hoy);      // 2026-08-05   sin cambios
System.out.println(futuro);   // 2026-09-04

De ahí se deriva la segunda propiedad automáticamente:

2. Seguridad entre hilos. Un objeto inmutable se puede compartir entre cualquier número de hilos sin sincronización (08-04). LocalDate, DateTimeFormatter, ZoneId: todos seguros.

3. API fluida. Los métodos se encadenan porque cada uno devuelve un objeto nuevo:

LocalDateTime vencimiento = LocalDateTime.now()
        .plusDays(21)
        .withHour(18)
        .withMinute(0)
        .withSecond(0)
        .withNano(0);

4. Explícita sobre la zona horaria. Esta es la decisión más importante y la que más problemas evita. En la API antigua, un Date era siempre un instante UTC que se mostraba en la zona por defecto de la JVM, y era imposible decir "el 15 de marzo" sin arrastrar una hora y una zona. En java.time, el nombre de la clase te dice exactamente qué llevas:

Prefijo Significado
Local... Sin zona horaria. Es una fecha o una hora "de calendario de pared"
Zoned... / Offset... Con zona horaria o con desfase respecto a UTC
Instant Un punto en la línea temporal, sin calendario ni zona

Nunca podrás confundirte, porque el tipo lo declara.

  1. Las clases núcleo y cómo elegir

graph TD
    A["¿Qué necesitas representar?"] --> B{"¿Importa la<br/>zona horaria?"}
    B -->|"No: fecha de calendario"| C{"¿Fecha, hora<br/>o ambas?"}
    C -->|"Solo fecha"| D["LocalDate<br/>2026-08-05"]
    C -->|"Solo hora"| E["LocalTime<br/>09:30"]
    C -->|"Ambas"| F["LocalDateTime<br/>2026-08-05T09:30"]
    B -->|"Sí: momento real"| G{"¿Para persona<br/>o para máquina?"}
    G -->|"Persona: mostrar"| H["ZonedDateTime<br/>con reglas de DST"]
    G -->|"Máquina: registrar"| I["Instant<br/>marca temporal UTC"]
    G -->|"Intercambio con desfase fijo"| J["OffsetDateTime<br/>+02:00"]
Clase Representa Ejemplo Caso de uso en BiblioTech
LocalDate Fecha sin hora ni zona 2026-08-05 Fecha de préstamo, vencimiento, cumpleaños
LocalTime Hora sin fecha ni zona 09:30:00 Hora de apertura de la biblioteca
LocalDateTime Fecha y hora sin zona 2026-08-05T09:30 Reserva de sala (interpretada en la zona local)
ZonedDateTime Fecha, hora y zona completa 2026-08-05T09:30+02:00[Europe/Madrid] Aviso programado que debe respetar el horario de verano
OffsetDateTime Fecha, hora y desfase fijo 2026-08-05T09:30+02:00 Intercambio con APIs, columnas de base de datos
Instant Punto en la línea temporal (UTC) 2026-08-05T07:30:00Z Marca de registro, auditoría, medición
Year Un año 2026 Estadísticas anuales
YearMonth Año y mes 2026-08 Informe mensual de préstamos
MonthDay Mes y día, sin año --12-25 Festivos que se repiten cada año
DayOfWeek Día de la semana (enum) WEDNESDAY Días hábiles
Month Mes (enum) AUGUST Estadísticas por mes
Duration Cantidad de tiempo (máquina) PT48H Duración de una sesión
Period Cantidad de tiempo (humano) P21D Plazo de préstamo

Cómo elegir, en tres preguntas:

  1. ¿Solo hay fecha, sin hora?LocalDate. Es el caso de la mayoría de campos de negocio: vencimientos, fechas de alta, plazos.
  2. ¿Es un momento que hay que registrar o comparar entre sistemas?Instant.
  3. ¿Hay que mostrárselo a un usuario en su zona, o programar algo respetando el horario de verano?ZonedDateTime.

Year, YearMonth y MonthDay son clases pequeñas que se olvidan y son muy útiles:

YearMonth agosto = YearMonth.of(2026, 8);
System.out.println(agosto.lengthOfMonth());        // 31
System.out.println(agosto.atDay(15));              // 2026-08-15
System.out.println(agosto.atEndOfMonth());         // 2026-08-31

MonthDay navidad = MonthDay.of(12, 25);
System.out.println(navidad.atYear(2026));          // 2026-12-25

Year año = Year.of(2026);
System.out.println(año.isLeap());                  // false
System.out.println(año.length());                  // 365

YearMonth es exactamente el tipo correcto para "el informe de agosto de 2026", y evita el apaño de guardar el día 1 en un LocalDate y acordarse de ignorarlo.

  1. Instant frente a LocalDateTime: la regla de oro

Esta distinción causa más bugs que ninguna otra, y merece entenderse bien.

LocalDateTime no representa un momento concreto. 2026-08-05T09:30 no identifica ningún punto de la línea temporal: en Madrid ocurre a una hora, en Tokio a otra, y en Nueva York a otra. Es un calendario de pared: "cuando el reloj marque las 9:30 del 5 de agosto, donde quiera que estés".

Instant sí representa un momento concreto. 2026-08-05T07:30:00Z es un punto único e inequívoco en la historia del universo. Todo el mundo está de acuerdo en cuándo ocurrió; solo discrepan en qué marcaba su reloj de pared.

LocalDateTime local = LocalDateTime.of(2026, 8, 5, 9, 30);

// El MISMO LocalDateTime en dos zonas son dos INSTANTES distintos
ZonedDateTime enMadrid = local.atZone(ZoneId.of("Europe/Madrid"));
ZonedDateTime enTokio  = local.atZone(ZoneId.of("Asia/Tokyo"));

System.out.println(enMadrid);              // 2026-08-05T09:30+02:00[Europe/Madrid]
System.out.println(enTokio);               // 2026-08-05T09:30+09:00[Asia/Tokyo]

System.out.println(enMadrid.toInstant());  // 2026-08-05T07:30:00Z
System.out.println(enTokio.toInstant());   // 2026-08-05T00:30:00Z

// Siete horas de diferencia entre dos "mismas" horas
System.out.println(Duration.between(enTokio.toInstant(), enMadrid.toInstant()));  // PT7H

La regla de oro

Guarda Instant (o OffsetDateTime en UTC). Muestra ZonedDateTime. Almacena momentos absolutos y conviértelos a la zona del usuario solo en el borde de la aplicación, al presentarlos.

Situación Tipo correcto Por qué
Columna "creado_en" en base de datos Instant / TIMESTAMP WITH TIME ZONE Inequívoco, comparable, ordenable
Marca de tiempo en un log Instant Correlacionable entre servidores en distintos husos
Medir cuánto tarda algo Instant + Duration Sin saltos de horario de verano
Fecha de vencimiento de un préstamo LocalDate Es una fecha de calendario, no un instante
Hora de apertura de la biblioteca LocalTime 9:00 es 9:00 en la zona de la biblioteca
Mostrar "creado el ..." al usuario ZonedDateTime en su zona Cada uno lo ve en su hora local
Programar un aviso a las 8:00 del lunes ZonedDateTime Debe respetar el cambio de horario

El error clásico es guardar LocalDateTime en la base de datos. Funciona perfectamente mientras el servidor, la base de datos y todos los usuarios están en la misma zona. El día que se despliega en un servidor en UTC o entra un usuario desde otro país, las horas se desplazan y los datos ya guardados son irrecuperables, porque nadie sabe en qué zona se escribieron.

  1. Creación: now, of, parse

Las tres vías, uniformes en todas las clases:

// --- now(): el momento actual ---
LocalDate hoy = LocalDate.now();
LocalTime ahora = LocalTime.now();
LocalDateTime ahoraCompleto = LocalDateTime.now();
ZonedDateTime enMadrid = ZonedDateTime.now(ZoneId.of("Europe/Madrid"));
Instant instante = Instant.now();

// --- of(): construir con valores explicitos ---
LocalDate fecha = LocalDate.of(2026, 8, 5);
LocalDate fecha2 = LocalDate.of(2026, Month.AUGUST, 5);      // con el enum: mas legible
LocalTime hora = LocalTime.of(9, 30);
LocalTime horaConSegundos = LocalTime.of(9, 30, 45);
LocalDateTime completa = LocalDateTime.of(2026, 8, 5, 9, 30);
LocalDateTime combinada = LocalDateTime.of(fecha, hora);      // combinando

// --- parse(): desde texto ISO-8601 ---
LocalDate p1 = LocalDate.parse("2026-08-05");
LocalTime p2 = LocalTime.parse("09:30:00");
LocalDateTime p3 = LocalDateTime.parse("2026-08-05T09:30:00");
ZonedDateTime p4 = ZonedDateTime.parse("2026-08-05T09:30:00+02:00[Europe/Madrid]");
Instant p5 = Instant.parse("2026-08-05T07:30:00Z");

// --- Conversiones entre tipos ---
LocalDateTime deFecha = fecha.atTime(9, 30);
LocalDateTime deFecha2 = fecha.atStartOfDay();               // 2026-08-05T00:00
LocalDate soloFecha = completa.toLocalDate();
LocalTime soloHora = completa.toLocalTime();
ZonedDateTime conZona = completa.atZone(ZoneId.of("Europe/Madrid"));
Instant aInstante = conZona.toInstant();

of valida. Un valor imposible lanza DateTimeException inmediatamente, no produce una fecha rara:

LocalDate.of(2026, 2, 30);
Exception in thread "main" java.time.DateTimeException:
    Invalid date 'FEBRUARY 30'

Compáralo con Calendar, que por defecto es "indulgente" y convertía el 30 de febrero en el 2 de marzo silenciosamente.

Los meses empiezan en 1. LocalDate.of(2026, 8, 5) es agosto. Y para máxima claridad existe el enum Month:

LocalDate.of(2026, Month.AUGUST, 5);

  1. Consulta: obtener partes y propiedades

LocalDate fecha = LocalDate.of(2026, 8, 5);

System.out.println(fecha.getYear());            // 2026
System.out.println(fecha.getMonthValue());      // 8
System.out.println(fecha.getMonth());           // AUGUST (enum Month)
System.out.println(fecha.getDayOfMonth());      // 5
System.out.println(fecha.getDayOfWeek());       // WEDNESDAY (enum DayOfWeek)
System.out.println(fecha.getDayOfYear());       // 217

System.out.println(fecha.lengthOfMonth());      // 31
System.out.println(fecha.lengthOfYear());       // 365
System.out.println(fecha.isLeapYear());         // false

LocalTime hora = LocalTime.of(9, 30, 45, 123_000_000);
System.out.println(hora.getHour());             // 9
System.out.println(hora.getMinute());           // 30
System.out.println(hora.getSecond());           // 45
System.out.println(hora.getNano());             // 123000000

Month y DayOfWeek son enum (04-07), lo que abre posibilidades:

DayOfWeek dia = fecha.getDayOfWeek();

// switch de expresion (02-03, y lo verás moderno en 10-06)
String tipo = switch (dia) {
    case SATURDAY, SUNDAY -> "fin de semana";
    default -> "día laborable";
};

// Nombre localizado
System.out.println(dia.getDisplayName(TextStyle.FULL, new Locale("es", "ES")));  // miércoles

// Aritmetica de dias
System.out.println(dia.plus(3));                // SATURDAY
System.out.println(dia.getValue());             // 3 (1=lunes ... 7=domingo, ISO)

Month mes = fecha.getMonth();
System.out.println(mes.length(false));          // 31 (false = no bisiesto)
System.out.println(mes.getDisplayName(TextStyle.FULL, new Locale("es", "ES")));  // agosto

Y para casos genéricos, get(TemporalField):

System.out.println(fecha.get(ChronoField.DAY_OF_WEEK));       // 3
System.out.println(fecha.get(ChronoField.ALIGNED_WEEK_OF_YEAR)); // 31

// Numero de semana ISO (la que usan los sistemas europeos)
System.out.println(fecha.get(WeekFields.ISO.weekOfWeekBasedYear()));  // 32

  1. Manipulación: métodos que devuelven copias

Tres familias de métodos, todos devolviendo objetos nuevos:

Prefijo Qué hace Ejemplo
plusX Suma plusDays(30), plusMonths(1), plusYears(1)
minusX Resta minusWeeks(2), minusHours(3)
withX Reemplaza un componente withDayOfMonth(1), withYear(2027)
LocalDate hoy = LocalDate.of(2026, 8, 5);

System.out.println(hoy.plusDays(21));           // 2026-08-26
System.out.println(hoy.plusWeeks(3));           // 2026-08-26
System.out.println(hoy.plusMonths(1));          // 2026-09-05
System.out.println(hoy.minusYears(1));          // 2025-08-05

System.out.println(hoy.withDayOfMonth(1));      // 2026-08-01
System.out.println(hoy.withMonth(12));          // 2026-12-05
System.out.println(hoy.withYear(2030));         // 2030-08-05

// Encadenamiento fluido
LocalDateTime vencimiento = LocalDateTime.of(2026, 8, 5, 14, 23, 51)
        .plusDays(21)
        .withHour(23)
        .withMinute(59)
        .withSecond(59);
System.out.println(vencimiento);                // 2026-08-26T23:59:59

El error clásico: ignorar el valor devuelto

LocalDate vencimiento = LocalDate.of(2026, 8, 5);
vencimiento.plusDays(21);                        // ¡NO HACE NADA!
System.out.println(vencimiento);                 // 2026-08-05

Los objetos son inmutables: plusDays no puede cambiar vencimiento. Devuelve una fecha nueva que, si no la asignas, se descarta. Es exactamente el mismo error que con String:

String s = "hola";
s.toUpperCase();                                 // no hace nada
System.out.println(s);                           // hola

s = s.toUpperCase();                             // asi si

Los IDE avisan de esto ("result of method is ignored"), y conviene hacerles caso.

La aritmética de meses no es trivial

LocalDate finDeEnero = LocalDate.of(2026, 1, 31);
System.out.println(finDeEnero.plusMonths(1));    // 2026-02-28  ¡no el 31!
System.out.println(finDeEnero.plusMonths(3));    // 2026-04-30  ¡no el 31!

// Y no es reversible
System.out.println(finDeEnero.plusMonths(1).minusMonths(1));   // 2026-01-28

java.time ajusta al último día válido del mes destino cuando el día no existe. Es la solución razonable, pero implica que sumar y restar un mes no siempre devuelve al punto de partida. Si tu lógica de negocio depende de eso, haz explícito el criterio en el código, no lo des por supuesto.

Los años bisiestos hacen lo mismo:

LocalDate bisiesto = LocalDate.of(2024, 2, 29);
System.out.println(bisiesto.plusYears(1));       // 2025-02-28

  1. Comparación

LocalDate a = LocalDate.of(2026, 8, 5);
LocalDate b = LocalDate.of(2026, 8, 26);

System.out.println(a.isBefore(b));               // true
System.out.println(a.isAfter(b));                // false
System.out.println(a.isEqual(b));                // false
System.out.println(a.equals(b));                 // false

// Tambien son Comparable (05-09), asi que se ordenan
List<LocalDate> fechas = new ArrayList<>(List.of(b, a));
Collections.sort(fechas);
System.out.println(fechas);                      // [2026-08-05, 2026-08-26]

// Y con streams (10-04)
Optional<LocalDate> masReciente = fechas.stream().max(Comparator.naturalOrder());

isEqual frente a equals: para LocalDate son equivalentes. La diferencia importa en ZonedDateTime y ChronoLocalDate, donde isEqual compara el instante y equals compara todos los campos incluida la zona:

ZonedDateTime madrid = ZonedDateTime.of(2026, 8, 5, 9, 30, 0, 0, ZoneId.of("Europe/Madrid"));
ZonedDateTime londres = madrid.withZoneSameInstant(ZoneId.of("Europe/London"));

System.out.println(madrid.isEqual(londres));     // true: es el MISMO instante
System.out.println(madrid.equals(londres));      // false: distinta zona y hora local

Regla: para saber si dos momentos son el mismo momento, usa isEqual o compara los Instant. equals en ZonedDateTime casi nunca es lo que quieres.

  1. Duration frente a Period

Dos clases para expresar "una cantidad de tiempo", y la diferencia es conceptual, no técnica.

Duration Period
Concepto Tiempo de máquina Tiempo humano
Unidades Segundos y nanosegundos Años, meses y días
Se aplica a Instant, LocalTime, LocalDateTime LocalDate, LocalDateTime
"Un día" es Exactamente 86.400 segundos Un día de calendario (puede tener 23 o 25 horas)
Formato ISO PT48H30M P1Y2M3D
// DURATION: tiempo de maquina
Duration d1 = Duration.ofHours(48);
Duration d2 = Duration.ofMinutes(90);
Duration d3 = Duration.ofSeconds(3600);
Duration d4 = Duration.between(Instant.now(), Instant.now().plusSeconds(7200));

System.out.println(d1);                          // PT48H
System.out.println(d1.toDays());                 // 2
System.out.println(d1.toHours());                // 48
System.out.println(d2.toMinutes());              // 90
System.out.println(d2.toHoursPart() + "h " + d2.toMinutesPart() + "m");   // 1h 30m

// PERIOD: tiempo humano
Period p1 = Period.ofDays(21);
Period p2 = Period.of(1, 2, 3);                  // 1 año, 2 meses, 3 dias
Period p3 = Period.between(LocalDate.of(2026, 1, 15), LocalDate.of(2026, 8, 5));

System.out.println(p1);                          // P21D
System.out.println(p3);                          // P6M21D
System.out.printf("%d años, %d meses, %d días%n",
        p3.getYears(), p3.getMonths(), p3.getDays());   // 0 años, 6 meses, 21 días

Por qué "un mes" no es un número fijo de días

LocalDate enero = LocalDate.of(2026, 1, 1);
LocalDate febrero = LocalDate.of(2026, 2, 1);

System.out.println(ChronoUnit.DAYS.between(enero, febrero));      // 31

LocalDate febrero2 = LocalDate.of(2026, 2, 1);
LocalDate marzo = LocalDate.of(2026, 3, 1);
System.out.println(ChronoUnit.DAYS.between(febrero2, marzo));     // 28

// Y en un año bisiesto
System.out.println(ChronoUnit.DAYS.between(
        LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1)));     // 29

Un mes son 28, 29, 30 o 31 días. Por eso Period.ofMonths(1) no se puede convertir a días sin una fecha de referencia:

Period unMes = Period.ofMonths(1);
System.out.println(unMes.getDays());             // 0, no 30: no hay dias en este periodo

// Para saber cuantos dias son, hace falta una fecha concreta
LocalDate desde = LocalDate.of(2026, 1, 31);
LocalDate hasta = desde.plus(unMes);
System.out.println(ChronoUnit.DAYS.between(desde, hasta));        // 28

La diferencia con el horario de verano

Aquí es donde Duration y Period divergen de verdad:

ZoneId madrid = ZoneId.of("Europe/Madrid");

// La noche del cambio a horario de verano: 29 de marzo de 2026
ZonedDateTime antes = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, madrid);

ZonedDateTime conPeriod = antes.plus(Period.ofDays(1));
ZonedDateTime conDuration = antes.plus(Duration.ofDays(1));

System.out.println("Origen:        " + antes);
System.out.println("+ Period 1D:   " + conPeriod);
System.out.println("+ Duration 1D: " + conDuration);
Origen:        2026-03-28T12:00+01:00[Europe/Madrid]
+ Period 1D:   2026-03-29T12:00+02:00[Europe/Madrid]
+ Duration 1D: 2026-03-29T13:00+02:00[Europe/Madrid]

Una hora de diferencia. Period.ofDays(1) dice "el mismo momento del día siguiente" y respeta el calendario: sigue siendo mediodía. Duration.ofDays(1) dice "86.400 segundos después", y como esa noche solo tuvo 23 horas, el resultado es la una de la tarde.

Cuál usar:

  • "Devolver el libro dentro de 21 días"Period. Es un plazo de calendario; si hay cambio de hora, sigue siendo el mismo día a la misma hora.
  • "La sesión expira en 30 minutos"Duration. Es tiempo real transcurrido.
  • "Cumpleaños dentro de un año"Period.
  • "El proceso tardó 4,2 segundos"Duration.

  1. ChronoUnit.between

Para "cuántas unidades hay entre dos fechas", ChronoUnit es la herramienta directa:

LocalDate prestamo = LocalDate.of(2026, 8, 5);
LocalDate vencimiento = LocalDate.of(2026, 8, 26);
LocalDate hoy = LocalDate.of(2026, 9, 3);

System.out.println(ChronoUnit.DAYS.between(prestamo, vencimiento));   // 21
System.out.println(ChronoUnit.DAYS.between(vencimiento, hoy));        // 8  (retraso)
System.out.println(ChronoUnit.WEEKS.between(prestamo, hoy));          // 4
System.out.println(ChronoUnit.MONTHS.between(prestamo, hoy));         // 0
System.out.println(ChronoUnit.YEARS.between(prestamo, hoy));          // 0

El truncamiento sorprende. MONTHS.between del 5 de agosto al 3 de septiembre da 0, no 1: no ha pasado un mes completo. between siempre trunca hacia cero, nunca redondea.

System.out.println(ChronoUnit.MONTHS.between(
        LocalDate.of(2026, 8, 5), LocalDate.of(2026, 9, 4)));     // 0
System.out.println(ChronoUnit.MONTHS.between(
        LocalDate.of(2026, 8, 5), LocalDate.of(2026, 9, 5)));     // 1

Y el signo indica la dirección:

System.out.println(ChronoUnit.DAYS.between(hoy, prestamo));       // -29 (negativo)

Con horas y momentos:

Instant inicio = Instant.now();
Instant fin = inicio.plusSeconds(9_045);

System.out.println(ChronoUnit.HOURS.between(inicio, fin));        // 2
System.out.println(ChronoUnit.MINUTES.between(inicio, fin));      // 150
System.out.println(ChronoUnit.SECONDS.between(inicio, fin));      // 9045

ChronoUnit.between frente a Period.between:

LocalDate a = LocalDate.of(2026, 1, 15);
LocalDate b = LocalDate.of(2027, 3, 20);

// ChronoUnit: UNA unidad, valor total
System.out.println(ChronoUnit.DAYS.between(a, b));      // 429
System.out.println(ChronoUnit.MONTHS.between(a, b));    // 14

// Period: DESCOMPUESTO en años, meses y dias
Period p = Period.between(a, b);
System.out.printf("%d años, %d meses, %d días%n",
        p.getYears(), p.getMonths(), p.getDays());      // 1 años, 2 meses, 5 días

Usa ChronoUnit para calcular ("lleva 8 días de retraso") y Period para presentar ("hace 1 año, 2 meses y 5 días").

  1. Ajustadores temporales: TemporalAdjusters

Un TemporalAdjuster es una operación que transforma una fecha según una regla. TemporalAdjusters trae las más útiles ya hechas, y resuelven cálculos que a mano son incómodos y propensos a errores.

LocalDate hoy = LocalDate.of(2026, 8, 5);        // miércoles

System.out.println(hoy.with(TemporalAdjusters.firstDayOfMonth()));      // 2026-08-01
System.out.println(hoy.with(TemporalAdjusters.lastDayOfMonth()));       // 2026-08-31
System.out.println(hoy.with(TemporalAdjusters.firstDayOfNextMonth()));  // 2026-09-01
System.out.println(hoy.with(TemporalAdjusters.firstDayOfYear()));       // 2026-01-01
System.out.println(hoy.with(TemporalAdjusters.lastDayOfYear()));        // 2026-12-31

// Dias de la semana
System.out.println(hoy.with(TemporalAdjusters.next(DayOfWeek.MONDAY)));         // 2026-08-10
System.out.println(hoy.with(TemporalAdjusters.nextOrSame(DayOfWeek.WEDNESDAY))); // 2026-08-05
System.out.println(hoy.with(TemporalAdjusters.previous(DayOfWeek.FRIDAY)));     // 2026-07-31

// El primer y ultimo dia concreto del mes
System.out.println(hoy.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY))); // 2026-08-03
System.out.println(hoy.with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY)));  // 2026-08-28
System.out.println(hoy.with(TemporalAdjusters.dayOfWeekInMonth(2, DayOfWeek.TUESDAY))); // 2026-08-11

La diferencia entre next y nextOrSame importa. next(WEDNESDAY) sobre un miércoles devuelve el siguiente miércoles; nextOrSame devuelve el mismo día. Elegir mal desplaza todos los vencimientos una semana.

Ajustadores propios: días hábiles en BiblioTech

Cuando la regla es de negocio, se escribe un ajustador propio. TemporalAdjuster es una interfaz funcional (04-06), así que puede ser una lambda:

package com.nexussoftware.bibliotech.servicio;

import java.time.*;
import java.time.temporal.*;
import java.util.Set;

/**
 * Ajustadores de calendario de Nexus Software.
 */
public final class AjustadoresBiblioTech {

    private AjustadoresBiblioTech() { }

    /** Festivos fijos de la biblioteca (MonthDay: se repiten cada año). */
    private static final Set<MonthDay> FESTIVOS = Set.of(
            MonthDay.of(1, 1),     // Año Nuevo
            MonthDay.of(1, 6),     // Reyes
            MonthDay.of(5, 1),     // Trabajo
            MonthDay.of(8, 15),    // Asunción
            MonthDay.of(10, 12),   // Fiesta Nacional
            MonthDay.of(11, 1),    // Todos los Santos
            MonthDay.of(12, 6),    // Constitución
            MonthDay.of(12, 8),    // Inmaculada
            MonthDay.of(12, 25));  // Navidad

    public static boolean esFestivo(LocalDate fecha) {
        return FESTIVOS.contains(MonthDay.from(fecha));
    }

    public static boolean esHabil(LocalDate fecha) {
        DayOfWeek dia = fecha.getDayOfWeek();
        return dia != DayOfWeek.SATURDAY
            && dia != DayOfWeek.SUNDAY
            && !esFestivo(fecha);
    }

    /**
     * Ajustador: mueve la fecha al siguiente dia habil si no lo es.
     * TemporalAdjuster es funcional: se puede escribir como lambda.
     */
    public static TemporalAdjuster siguienteDiaHabil() {
        return temporal -> {
            LocalDate fecha = LocalDate.from(temporal);
            while (!esHabil(fecha)) {
                fecha = fecha.plusDays(1);
            }
            return temporal.with(fecha);
        };
    }

    /** Suma N dias HABILES, saltando fines de semana y festivos. */
    public static TemporalAdjuster masDiasHabiles(int dias) {
        return temporal -> {
            LocalDate fecha = LocalDate.from(temporal);
            int restantes = dias;
            while (restantes > 0) {
                fecha = fecha.plusDays(1);
                if (esHabil(fecha)) {
                    restantes--;
                }
            }
            return temporal.with(fecha);
        };
    }

    /** Cuenta dias habiles entre dos fechas (para multas justas). */
    public static long diasHabilesEntre(LocalDate desde, LocalDate hasta) {
        return desde.datesUntil(hasta)                  // Stream<LocalDate> (Java 9)
                    .filter(AjustadoresBiblioTech::esHabil)
                    .count();
    }
}
LocalDate viernes = LocalDate.of(2026, 8, 14);

System.out.println("Viernes 14:            " + viernes);
System.out.println("+ 1 día natural:       " + viernes.plusDays(1));
System.out.println("Ajustado a hábil:      "
        + viernes.plusDays(1).with(AjustadoresBiblioTech.siguienteDiaHabil()));
System.out.println("+ 5 días hábiles:      "
        + viernes.with(AjustadoresBiblioTech.masDiasHabiles(5)));
System.out.println("Días hábiles de agosto: "
        + AjustadoresBiblioTech.diasHabilesEntre(
                LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)));
Viernes 14:            2026-08-14
+ 1 día natural:       2026-08-15
Ajustado a hábil:      2026-08-17
+ 5 días hábiles:      2026-08-24
Días hábiles de agosto: 20

El 15 de agosto es sábado y festivo, así que el ajustador salta al lunes 17. Y observa datesUntil, añadido en Java 9: devuelve un Stream<LocalDate> con todas las fechas del rango, lo que conecta directamente con 10-04.

  1. Zonas horarias: ZoneId y ZoneOffset

ZoneId identifica una zona con sus reglas históricas y futuras: Europe/Madrid sabe cuándo empieza y acaba el horario de verano cada año, y sabe que en 1975 las reglas eran otras.

ZoneOffset es solo un desfase fijo respecto a UTC: +02:00. No sabe nada de reglas.

// ZoneId: identificador IANA "Region/Ciudad"
ZoneId madrid = ZoneId.of("Europe/Madrid");
ZoneId tokio = ZoneId.of("Asia/Tokyo");
ZoneId nuevaYork = ZoneId.of("America/New_York");
ZoneId utc = ZoneId.of("UTC");
ZoneId porDefecto = ZoneId.systemDefault();

// ZoneOffset: desfase fijo
ZoneOffset masDos = ZoneOffset.ofHours(2);
ZoneOffset cero = ZoneOffset.UTC;

// Cuantas zonas hay
System.out.println(ZoneId.getAvailableZoneIds().size());    // unas 600

La base de datos IANA (también llamada tz database o Olson database) es el registro mundial de zonas horarias, mantenido colaborativamente y actualizado varias veces al año porque los gobiernos cambian las reglas. Java la incluye y se actualiza con las versiones del JDK y con la herramienta tzupdater.

Usa siempre identificadores Region/Ciudad. Las abreviaturas de tres letras (CST, IST, EST) están obsoletas y son ambiguas: CST puede ser Central Standard Time (EE. UU.), China Standard Time o Cuba Standard Time.

Conversión entre zonas

ZonedDateTime enMadrid = ZonedDateTime.of(2026, 8, 5, 9, 30, 0, 0, ZoneId.of("Europe/Madrid"));

// MISMO instante, otra zona: la hora local CAMBIA
ZonedDateTime enTokio = enMadrid.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));

// MISMA hora local, otra zona: el instante CAMBIA
ZonedDateTime otroInstante = enMadrid.withZoneSameLocal(ZoneId.of("Asia/Tokyo"));

System.out.println("Madrid:              " + enMadrid);
System.out.println("Mismo instante:      " + enTokio);
System.out.println("Misma hora local:    " + otroInstante);
System.out.println("¿Mismo instante?     " + enMadrid.isEqual(enTokio));
System.out.println("¿Mismo instante?     " + enMadrid.isEqual(otroInstante));
Madrid:              2026-08-05T09:30+02:00[Europe/Madrid]
Mismo instante:      2026-08-05T16:30+09:00[Asia/Tokyo]
Misma hora local:    2026-08-05T09:30+09:00[Asia/Tokyo]
¿Mismo instante?     true
¿Mismo instante?     false

Los dos métodos hacen cosas opuestas y los dos son necesarios:

  • withZoneSameInstant: "¿qué hora era en Tokio cuando en Madrid eran las 9:30?" Es lo que usas para mostrar un momento a un usuario en otra zona.
  • withZoneSameLocal: "la reunión es a las 9:30 hora de Tokio". Cambia el momento real.

El 99 % de las veces quieres withZoneSameInstant.

El panel horario de BiblioTech

package com.nexussoftware.bibliotech.presentacion;

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;

public class RelojMundialBiblioTech {

    private static final DateTimeFormatter FORMATO =
            DateTimeFormatter.ofPattern("EEEE dd/MM/yyyy HH:mm", new Locale("es", "ES"));

    public static void main(String[] args) {

        Instant ahora = Instant.now();

        List<String> sedes = List.of(
                "Europe/Madrid", "Europe/London", "America/New_York",
                "Asia/Tokyo", "Australia/Sydney", "UTC");

        System.out.println("Sedes de Nexus Software -- instante: " + ahora);
        System.out.println("-".repeat(62));

        sedes.stream()
             .map(ZoneId::of)
             .map(zona -> ahora.atZone(zona))
             .sorted(java.util.Comparator.comparing(z -> z.getOffset().getTotalSeconds()))
             .forEach(z -> System.out.printf("  %-20s %-32s %s%n",
                     z.getZone(), z.format(FORMATO), z.getOffset()));
    }
}
Sedes de Nexus Software -- instante: 2026-08-05T12:47:31.204Z
--------------------------------------------------------------
  America/New_York     miércoles 05/08/2026 08:47            -04:00
  UTC                  miércoles 05/08/2026 12:47            Z
  Europe/London        miércoles 05/08/2026 13:47            +01:00
  Europe/Madrid        miércoles 05/08/2026 14:47            +02:00
  Asia/Tokyo           miércoles 05/08/2026 21:47            +09:00
  Australia/Sydney     miércoles 05/08/2026 22:47            +10:00

Un solo Instant, seis relojes de pared distintos. Eso es exactamente la regla de oro del apartado 5 en acción.

  1. El horario de verano y sus dos casos peligrosos

El cambio de hora produce dos anomalías que rompen el código escrito sin pensar en ellas. java.time las gestiona, pero hay que saber qué hace.

Caso 1: la hora que no existe (salto adelante)

En España, la madrugada del último domingo de marzo, a las 2:00 los relojes saltan a las 3:00. Las 2:30 de esa noche no existen.

package com.nexussoftware.bibliotech;

import java.time.*;

public class HoraQueNoExiste {

    public static void main(String[] args) {

        ZoneId madrid = ZoneId.of("Europe/Madrid");

        // 29 de marzo de 2026: cambio a horario de verano
        LocalDateTime inexistente = LocalDateTime.of(2026, 3, 29, 2, 30);

        System.out.println("LocalDateTime:  " + inexistente);

        ZonedDateTime resultado = inexistente.atZone(madrid);
        System.out.println("atZone():       " + resultado);
        System.out.println("Desfase:        " + resultado.getOffset());

        // Comprobarlo explicitamente
        var reglas = madrid.getRules();
        System.out.println("¿Es un hueco?   " + (reglas.getTransition(inexistente) != null));
        System.out.println("Duración salto: "
                + reglas.getTransition(inexistente).getDuration());
    }
}
LocalDateTime:  2026-03-29T02:30
atZone():       2026-03-29T03:30+02:00[Europe/Madrid]
Desfase:        +02:00
¿Es un hueco?   true
Duración salto: PT1H

atZone no lanza excepción: desplaza la hora hacia adelante el tamaño del salto. Las 2:30 se convierten en las 3:30.

Consecuencia práctica para BiblioTech: un aviso programado para las 2:30 de esa madrugada se ejecutará a las 3:30. Si el sistema programa tareas nocturnas, conviene evitar la franja de 2:00 a 3:00 en las zonas con cambio horario.

Caso 2: la hora que ocurre dos veces (salto atrás)

El último domingo de octubre, a las 3:00 los relojes retroceden a las 2:00. Las 2:30 ocurren dos veces, con desfases distintos.

package com.nexussoftware.bibliotech;

import java.time.*;

public class HoraDuplicada {

    public static void main(String[] args) {

        ZoneId madrid = ZoneId.of("Europe/Madrid");

        // 25 de octubre de 2026: vuelta al horario de invierno
        LocalDateTime ambigua = LocalDateTime.of(2026, 10, 25, 2, 30);

        System.out.println("LocalDateTime:      " + ambigua);

        // Por defecto elige la PRIMERA ocurrencia (verano, +02:00)
        ZonedDateTime porDefecto = ambigua.atZone(madrid);
        System.out.println("atZone() por defecto: " + porDefecto);

        // Eleccion explicita
        ZonedDateTime primera = ambigua.atZone(madrid).withEarlierOffsetAtOverlap();
        ZonedDateTime segunda = ambigua.atZone(madrid).withLaterOffsetAtOverlap();

        System.out.println("Primera (verano):     " + primera);
        System.out.println("Segunda (invierno):   " + segunda);
        System.out.println("Diferencia real:      "
                + Duration.between(primera.toInstant(), segunda.toInstant()));

        var reglas = madrid.getRules();
        System.out.println("Desfases válidos:     " + reglas.getValidOffsets(ambigua));
    }
}
LocalDateTime:      2026-10-25T02:30
atZone() por defecto: 2026-10-25T02:30+02:00[Europe/Madrid]
Primera (verano):     2026-10-25T02:30+02:00[Europe/Madrid]
Segunda (invierno):   2026-10-25T02:30+01:00[Europe/Madrid]
Diferencia real:      PT1H
Desfases válidos:     [+02:00, +01:00]

Dos instantes reales separados por una hora, con el mismo reloj de pared. getValidOffsets devuelve dos desfases, y eso es la señal inequívoca de que la hora es ambigua.

Consecuencias reales:

  • Un préstamo registrado a las 2:30 de esa madrugada podría parecer que ocurrió antes que otro registrado a las 2:15... una hora después.
  • Ordenar por LocalDateTime da un orden incorrecto; ordenar por Instant da el orden real.
  • Una tarea programada a las 2:30 puede ejecutarse dos veces.

La conclusión operativa

Regla Motivo
Guarda Instant, no LocalDateTime Los instantes no son ambiguos ni inexistentes
Ordena y compara por Instant El orden de los relojes de pared puede mentir
Mide duraciones con Instant + Duration Restar LocalDateTime incluye el salto horario
Evita programar tareas entre las 2:00 y las 3:00 Es la franja de las dos anomalías
Usa Period para plazos de calendario Sobrevive al cambio de hora sin desplazarse

Y la razón final por la que BiblioTech usará LocalDate para las fechas de préstamo: las fechas de calendario no tienen este problema. El 5 de agosto es el 5 de agosto, no hay horas que falten ni que se repitan.

  1. Formateo y parseo con DateTimeFormatter

DateTimeFormatter sustituye a SimpleDateFormat y es inmutable y seguro entre hilos.

Formatos predefinidos

LocalDate fecha = LocalDate.of(2026, 8, 5);
LocalDateTime completa = LocalDateTime.of(2026, 8, 5, 9, 30, 45);
ZonedDateTime conZona = completa.atZone(ZoneId.of("Europe/Madrid"));

System.out.println(fecha.format(DateTimeFormatter.ISO_DATE));            // 2026-08-05
System.out.println(completa.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); // 2026-08-05T09:30:45
System.out.println(conZona.format(DateTimeFormatter.ISO_ZONED_DATE_TIME));
// 2026-08-05T09:30:45+02:00[Europe/Madrid]
System.out.println(conZona.format(DateTimeFormatter.ISO_INSTANT));       // 2026-08-05T07:30:45Z
System.out.println(fecha.format(DateTimeFormatter.BASIC_ISO_DATE));      // 20260805

ISO-8601 es el formato de intercambio. 2026-08-05 es inequívoco en todo el mundo; 05/08/2026 es el 5 de agosto en España y el 8 de mayo en Estados Unidos. Para persistir e intercambiar datos, siempre ISO. Los formatos localizados son solo para mostrar.

Y el toString() de todas las clases de java.time ya produce ISO-8601, lo que hace que serializar sea trivial.

Patrones propios

DateTimeFormatter español = DateTimeFormatter.ofPattern("dd/MM/yyyy");
DateTimeFormatter conHora = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss");
DateTimeFormatter legible = DateTimeFormatter.ofPattern("EEEE, d 'de' MMMM 'de' yyyy",
                                                        new Locale("es", "ES"));

System.out.println(fecha.format(español));       // 05/08/2026
System.out.println(completa.format(conHora));    // 05/08/2026 09:30:45
System.out.println(fecha.format(legible));       // miércoles, 5 de agosto de 2026

Las letras de patrón más usadas:

Letra Significado Ejemplo
y Año yyyy → 2026, yy → 26
M Mes M → 8, MM → 08, MMM → ago, MMMM → agosto
d Día del mes d → 5, dd → 05
E Día de la semana EEE → mié, EEEE → miércoles
H Hora 0-23 HH → 09
h Hora 1-12 hh → 09
m Minuto mm → 30
s Segundo ss → 45
S Fracción de segundo SSS → 123
a AM/PM a → a. m.
z Nombre de la zona z → CEST
Z Desfase Z → +0200
X Desfase ISO XXX → +02:00
'texto' Literal 'de' → de

El error clásico: confundir MM con mm. MM es el mes, mm son los minutos. "dd/mm/yyyy" produce 05/30/2026, con los minutos donde debería ir el mes.

Y el segundo: YYYY en lugar de yyyy. Y es el "año basado en semanas", que difiere del año natural en los últimos días de diciembre y los primeros de enero. Usar YYYY produce el bug famoso de que el 31 de diciembre de 2026 se formatea como 2027. Usa yyyy siempre.

Parseo y DateTimeParseException

DateTimeFormatter formato = DateTimeFormatter.ofPattern("dd/MM/yyyy");

LocalDate fecha = LocalDate.parse("05/08/2026", formato);
System.out.println(fecha);                       // 2026-08-05

// Sin formateador: se espera ISO-8601
LocalDate iso = LocalDate.parse("2026-08-05");

El parseo fallido lanza DateTimeParseException, que es no comprobada (extiende RuntimeException), así que el compilador no obliga a capturarla:

package com.nexussoftware.bibliotech.presentacion;

import java.time.LocalDate;
import java.time.format.*;
import java.util.Optional;

public class LectorDeFechas {

    private static final DateTimeFormatter ENTRADA =
            DateTimeFormatter.ofPattern("dd/MM/yyyy");

    /** Devuelve Optional en lugar de lanzar: la entrada mala del usuario es esperable (10-04). */
    public static Optional<LocalDate> leer(String texto) {
        if (texto == null || texto.isBlank()) {
            return Optional.empty();
        }
        try {
            return Optional.of(LocalDate.parse(texto.strip(), ENTRADA));
        } catch (DateTimeParseException e) {
            System.out.printf("  Fecha no válida: \"%s\" (posición %d: %s)%n",
                    e.getParsedString(), e.getErrorIndex(), e.getMessage());
            return Optional.empty();
        }
    }

    public static void main(String[] args) {
        for (String entrada : new String[] {
                "05/08/2026", "31/02/2026", "2026-08-05", "cinco de agosto", "  05/08/2026  " }) {

            System.out.println("Entrada: \"" + entrada + "\"");
            leer(entrada).ifPresentOrElse(
                    f  -> System.out.println("  -> " + f + " (" + f.getDayOfWeek() + ")"),
                    () -> System.out.println("  -> descartada"));
        }
    }
}
Entrada: "05/08/2026"
  -> 2026-08-05 (WEDNESDAY)
Entrada: "31/02/2026"
  Fecha no válida: "31/02/2026" (posición 0: Text '31/02/2026' could not be parsed:
    Invalid date 'FEBRUARY 31')
  -> descartada
Entrada: "2026-08-05"
  Fecha no válida: "2026-08-05" (posición 0: Text '2026-08-05' could not be parsed at index 2)
  -> descartada
Entrada: "cinco de agosto"
  Fecha no válida: "cinco de agosto" (posición 0: ...)
  -> descartada
Entrada: "  05/08/2026  "
  -> 2026-08-05 (WEDNESDAY)

DateTimeParseException trae getParsedString() y getErrorIndex(), que permiten mensajes de error precisos — mucho mejor que el genérico de SimpleDateFormat.

Construir formateadores complejos

Para casos que un patrón no cubre, existe DateTimeFormatterBuilder:

DateTimeFormatter tolerante = new DateTimeFormatterBuilder()
        .parseCaseInsensitive()                       // acepta "AGO" y "ago"
        .appendPattern("dd/MM/yyyy")
        .optionalStart()                              // la hora es opcional
        .appendPattern(" HH:mm")
        .optionalEnd()
        .parseDefaulting(ChronoField.HOUR_OF_DAY, 0)  // si falta, medianoche
        .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
        .toFormatter(new Locale("es", "ES"));

System.out.println(LocalDateTime.parse("05/08/2026", tolerante));         // 2026-08-05T00:00
System.out.println(LocalDateTime.parse("05/08/2026 14:30", tolerante));   // 2026-08-05T14:30

Un solo formateador que acepta las dos formas. Es exactamente lo que necesita un importador de CSV que recibe datos de varias fuentes.

  1. Locale: nombres de mes y día en español

Sin Locale, los nombres salen en el idioma por defecto de la JVM, que en un servidor suele ser inglés:

LocalDate fecha = LocalDate.of(2026, 8, 5);

DateTimeFormatter sinLocale = DateTimeFormatter.ofPattern("EEEE d 'de' MMMM");
DateTimeFormatter conEspanol = DateTimeFormatter.ofPattern("EEEE d 'de' MMMM",
                                                           new Locale("es", "ES"));
DateTimeFormatter conCatalan = DateTimeFormatter.ofPattern("EEEE d 'de' MMMM",
                                                           new Locale("ca", "ES"));

System.out.println(fecha.format(sinLocale));     // Wednesday 5 de August  (si la JVM esta en ingles)
System.out.println(fecha.format(conEspanol));    // miércoles 5 de agosto
System.out.println(fecha.format(conCatalan));    // dimecres 5 de agost

Nunca dependas del Locale por defecto en código de producción. Cambia entre la máquina de desarrollo y el servidor, y produce salidas distintas sin ninguna advertencia.

También hay formatos localizados predefinidos, que respetan las convenciones de cada país:

Locale es = new Locale("es", "ES");

System.out.println(fecha.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(es)));
// 5/8/26
System.out.println(fecha.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(es)));
// 5 ago 2026
System.out.println(fecha.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(es)));
// 5 de agosto de 2026
System.out.println(fecha.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(es)));
// miércoles, 5 de agosto de 2026

Y TextStyle para nombres sueltos:

Locale es = new Locale("es", "ES");

System.out.println(DayOfWeek.WEDNESDAY.getDisplayName(TextStyle.FULL, es));    // miércoles
System.out.println(DayOfWeek.WEDNESDAY.getDisplayName(TextStyle.SHORT, es));   // mié
System.out.println(Month.AUGUST.getDisplayName(TextStyle.FULL, es));           // agosto
System.out.println(Month.AUGUST.getDisplayName(TextStyle.SHORT, es));          // ago

  1. Interoperabilidad con la API antigua y con FileTime

En código real te encontrarás Date, Calendar y FileTime. Las conversiones son directas.

Date y Calendar

// Date -> Instant -> java.time
Date fechaAntigua = new Date();
Instant instante = fechaAntigua.toInstant();
LocalDateTime local = instante.atZone(ZoneId.systemDefault()).toLocalDateTime();
LocalDate soloFecha = instante.atZone(ZoneId.systemDefault()).toLocalDate();

// java.time -> Date
Instant deVuelta = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant();
Date fechaAntigua2 = Date.from(deVuelta);

// Calendar
Calendar cal = Calendar.getInstance();
ZonedDateTime deCalendar = ((GregorianCalendar) cal).toZonedDateTime();
GregorianCalendar aCalendar = GregorianCalendar.from(ZonedDateTime.now());

// java.sql
java.sql.Date sqlDate = java.sql.Date.valueOf(LocalDate.now());
LocalDate deSql = sqlDate.toLocalDate();
java.sql.Timestamp ts = java.sql.Timestamp.valueOf(LocalDateTime.now());
LocalDateTime deTs = ts.toLocalDateTime();

El puente universal es Instant. Casi todas las conversiones pasan por él, porque es el único tipo que representa lo mismo que un Date (un punto en la línea temporal).

Cuidado con la asimetría: Date es un instante; LocalDateTime no lo es. Convertir de LocalDateTime a Date exige elegir una zona, y la elección por defecto (systemDefault()) es una decisión implícita que puede morderte.

FileTime: retomando 07-06

En 07-06 leíste los atributos de un fichero y las marcas de tiempo salían como FileTime, un tipo del que no podías hacer nada. Ahora sí:

package com.nexussoftware.bibliotech.persistencia;

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.*;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;

public class InventarioDeRespaldos {

    private static final DateTimeFormatter FORMATO =
            DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", new Locale("es", "ES"));

    private static final ZoneId ZONA = ZoneId.of("Europe/Madrid");

    public record Respaldo(Path ruta, LocalDateTime modificado, long bytes) {

        public long diasDeAntiguedad(LocalDate hoy) {
            return java.time.temporal.ChronoUnit.DAYS.between(modificado.toLocalDate(), hoy);
        }
    }

    /** Lista los respaldos con su fecha real, no con un FileTime opaco. */
    public List<Respaldo> inventariar(Path directorio) throws IOException {
        try (Stream<Path> rutas = Files.list(directorio)) {
            return rutas
                    .filter(Files::isRegularFile)
                    .filter(p -> p.getFileName().toString().endsWith(".bak"))
                    .map(this::describir)
                    .sorted(java.util.Comparator.comparing(Respaldo::modificado).reversed())
                    .toList();
        }
    }

    private Respaldo describir(Path ruta) {
        try {
            BasicFileAttributes atributos = Files.readAttributes(ruta, BasicFileAttributes.class);

            // FileTime -> Instant -> LocalDateTime en la zona de la biblioteca
            FileTime modificado = atributos.lastModifiedTime();
            LocalDateTime fecha = modificado.toInstant().atZone(ZONA).toLocalDateTime();

            return new Respaldo(ruta, fecha, atributos.size());

        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    /** Marca un fichero con una fecha concreta: java.time -> FileTime. */
    public void fecharComo(Path fichero, LocalDateTime cuando) throws IOException {
        Instant instante = cuando.atZone(ZONA).toInstant();
        Files.setLastModifiedTime(fichero, FileTime.from(instante));
    }

    /** Borra los respaldos anteriores a la fecha de corte. */
    public int purgarAnterioresA(Path directorio, LocalDate corte) throws IOException {
        List<Respaldo> caducados = inventariar(directorio).stream()
                .filter(r -> r.modificado().toLocalDate().isBefore(corte))
                .toList();

        for (Respaldo r : caducados) {
            Files.delete(r.ruta());
        }
        return caducados.size();
    }

    public static void main(String[] args) throws IOException {

        InventarioDeRespaldos inventario = new InventarioDeRespaldos();
        LocalDate hoy = LocalDate.now(ZONA);

        System.out.printf("%-28s %-18s %10s %8s%n",
                "FICHERO", "MODIFICADO", "TAMAÑO", "DÍAS");
        System.out.println("-".repeat(68));

        inventario.inventariar(Path.of("respaldos")).forEach(r ->
                System.out.printf("%-28s %-18s %8d KB %8d%n",
                        r.ruta().getFileName(),
                        r.modificado().format(FORMATO),
                        r.bytes() / 1024,
                        r.diasDeAntiguedad(hoy)));

        LocalDate corte = hoy.minusMonths(3).with(
                java.time.temporal.TemporalAdjusters.firstDayOfMonth());
        System.out.println("\nPurgando respaldos anteriores a " + corte + "...");
        System.out.println("Eliminados: " + inventario.purgarAnterioresA(Path.of("respaldos"), corte));
    }
}
FICHERO                      MODIFICADO             TAMAÑO     DÍAS
--------------------------------------------------------------------
catalogo-20260804.bak        04/08/2026 03:00        1284 KB        1
catalogo-20260728.bak        28/07/2026 03:00        1271 KB        8
catalogo-20260630.bak        30/06/2026 03:00        1198 KB       36
catalogo-20260401.bak        01/04/2026 03:00        1044 KB      126

Purgando respaldos anteriores a 2026-05-01...
Eliminados: 1

FileTime.toInstant() y FileTime.from(Instant) son el puente en las dos direcciones. La deuda de 07-06 queda saldada.

  1. Clock: la fuente de tiempo inyectable

Este apartado parece técnico y es, en realidad, uno de los más importantes de la lección.

El problema: este código es imposible de probar de forma fiable.

public class CalculadoraMultas {

    public double calcular(Prestamo prestamo) {
        LocalDate hoy = LocalDate.now();                    // <-- el problema
        long retraso = ChronoUnit.DAYS.between(prestamo.getFechaVencimiento(), hoy);
        return retraso <= 0 ? 0.0 : retraso * 0.25;
    }
}

LocalDate.now() lee el reloj del sistema. Para probar "un préstamo con 30 días de retraso" tendrías que:

  • Cambiar la hora del sistema (inviable en integración continua).
  • Crear el préstamo con fecha de vencimiento hace 30 días, lo que hace la prueba dependiente del día en que se ejecute — y fallará el día que caiga en un caso límite.
  • No probar el caso, que es lo que suele pasar.

La solución: Clock, una abstracción de la fuente de tiempo que se inyecta en lugar de leerse globalmente.

// Reloj del sistema en la zona por defecto
Clock sistema = Clock.systemDefaultZone();

// Reloj del sistema en una zona concreta
Clock madrid = Clock.system(ZoneId.of("Europe/Madrid"));

// Reloj FIJO: siempre devuelve el mismo instante
Clock fijo = Clock.fixed(Instant.parse("2026-08-05T10:00:00Z"), ZoneId.of("Europe/Madrid"));

// Reloj desplazado respecto a otro
Clock dentroDe30Dias = Clock.offset(sistema, Duration.ofDays(30));

// Reloj con granularidad reducida (util para pruebas deterministas)
Clock porSegundos = Clock.tickSeconds(ZoneId.of("Europe/Madrid"));

Todos los now() aceptan un Clock:

LocalDate.now(reloj);
LocalDateTime.now(reloj);
ZonedDateTime.now(reloj);
Instant.now(reloj);

La versión probable de la calculadora:

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Prestamo;

import java.time.*;
import java.time.temporal.ChronoUnit;

/**
 * Calculadora de multas con reloj INYECTADO.
 * En produccion recibe el reloj del sistema; en pruebas, uno fijo.
 */
public class CalculadoraMultas {

    private static final double EURO_POR_DIA = 0.25;
    private static final double TOPE = 20.0;
    private static final int DIAS_DE_CORTESIA = 2;

    private final Clock reloj;

    /** Constructor de produccion. */
    public CalculadoraMultas() {
        this(Clock.system(ZoneId.of("Europe/Madrid")));
    }

    /** Constructor para pruebas y para escenarios con zona explicita. */
    public CalculadoraMultas(Clock reloj) {
        this.reloj = java.util.Objects.requireNonNull(reloj, "reloj");
    }

    public LocalDate hoy() {
        return LocalDate.now(reloj);        // nunca LocalDate.now() a secas
    }

    public long diasDeRetraso(Prestamo prestamo) {
        long dias = ChronoUnit.DAYS.between(prestamo.getFechaVencimiento(), hoy());
        return Math.max(0, dias);
    }

    public double calcular(Prestamo prestamo) {
        long retraso = diasDeRetraso(prestamo);
        if (retraso <= DIAS_DE_CORTESIA) {
            return 0.0;
        }
        return Math.min((retraso - DIAS_DE_CORTESIA) * EURO_POR_DIA, TOPE);
    }

    public boolean estaVencido(Prestamo prestamo) {
        return hoy().isAfter(prestamo.getFechaVencimiento());
    }

    public boolean venceEnLosProximos(Prestamo prestamo, int dias) {
        LocalDate limite = hoy().plusDays(dias);
        LocalDate vencimiento = prestamo.getFechaVencimiento();
        return !vencimiento.isBefore(hoy()) && !vencimiento.isAfter(limite);
    }
}

Y ahora la prueba es determinista y no depende del día de ejecución:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.dominio.Prestamo;
import com.nexussoftware.bibliotech.servicio.CalculadoraMultas;

import java.time.*;

public class PruebaCalculadoraMultas {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");

    public static void main(String[] args) {

        // El "hoy" de la prueba es FIJO: 5 de agosto de 2026
        Clock hoyFijo = Clock.fixed(Instant.parse("2026-08-05T10:00:00Z"), MADRID);
        CalculadoraMultas calculadora = new CalculadoraMultas(hoyFijo);

        System.out.printf("%-24s %-12s %8s %10s%n",
                "CASO", "VENCIMIENTO", "RETRASO", "MULTA");
        System.out.println("-".repeat(58));

        comprobar(calculadora, "Sin vencer",              LocalDate.of(2026, 8, 20));
        comprobar(calculadora, "Vence hoy",               LocalDate.of(2026, 8,  5));
        comprobar(calculadora, "1 día (cortesía)",        LocalDate.of(2026, 8,  4));
        comprobar(calculadora, "2 días (cortesía)",       LocalDate.of(2026, 8,  3));
        comprobar(calculadora, "3 días (primera multa)",  LocalDate.of(2026, 8,  2));
        comprobar(calculadora, "30 días",                 LocalDate.of(2026, 7,  6));
        comprobar(calculadora, "1 año (tope)",            LocalDate.of(2025, 8,  5));

        // Y ahora "viajamos" 100 dias al futuro sin tocar el reloj del sistema
        System.out.println("\n--- El mismo préstamo, 100 días después ---");
        Clock futuro = Clock.offset(hoyFijo, Duration.ofDays(100));
        CalculadoraMultas enElFuturo = new CalculadoraMultas(futuro);
        System.out.println("Hoy para la calculadora: " + enElFuturo.hoy());
        comprobar(enElFuturo, "Sin vencer (antes)", LocalDate.of(2026, 8, 20));
    }

    private static void comprobar(CalculadoraMultas c, String caso, LocalDate vencimiento) {
        Prestamo p = new Prestamo("PR-TEST", "978-0000000001", "Marta Ruiz",
                                  vencimiento.minusDays(21), vencimiento);
        System.out.printf("%-24s %-12s %8d %8.2f €%n",
                caso, vencimiento, c.diasDeRetraso(p), c.calcular(p));
    }
}
CASO                     VENCIMIENTO   RETRASO      MULTA
----------------------------------------------------------
Sin vencer               2026-08-20          0     0,00 €
Vence hoy                2026-08-05          0     0,00 €
1 día (cortesía)         2026-08-04          1     0,00 €
2 días (cortesía)        2026-08-03          2     0,00 €
3 días (primera multa)   2026-08-02          3     0,25 €
30 días                  2026-07-06         30     7,00 €
1 año (tope)             2025-08-05        365    20,00 €

--- El mismo préstamo, 100 días después ---
Hoy para la calculadora: 2026-11-13
Sin vencer (antes)       2026-08-20         85    20,00 €

Esta prueba dará el mismo resultado dentro de cinco años, porque no depende del reloj del sistema. Los casos límite —vence hoy, un día de retraso, exactamente los días de cortesía, el tope— se comprueban de forma explícita y reproducible.

La regla: en cualquier clase cuya lógica dependa de "ahora", inyecta un Clock y nunca llames a LocalDate.now() sin argumento. En 11-04 verás que es exactamente lo que hace posible probar código con fechas, y en 11-02 que Spring puede inyectar el Clock como cualquier otra dependencia.

  1. BiblioTech: la migración completa

Aplicamos todo. Empezando por Prestamo:

package com.nexussoftware.bibliotech.dominio;

import com.nexussoftware.bibliotech.anotaciones.CampoCsv;

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;

/**
 * Prestamo con fechas REALES.
 *
 * ANTES:  private final int diaPrestamo;
 *         private final int diaVencimiento;
 * AHORA:  LocalDate, porque un prestamo es un hecho de CALENDARIO,
 *         no un instante: no le afecta el horario de verano.
 */
public class Prestamo implements Identificable {

    /** Plazo estandar de prestamo en Nexus Software. */
    public static final Period PLAZO_ESTANDAR = Period.ofDays(21);

    /** ISO-8601 para persistir: inequivoco en cualquier pais y sistema. */
    private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;

    @CampoCsv(nombre = "Referencia", orden = 1)
    private final String referencia;

    @CampoCsv(nombre = "ISBN", orden = 2)
    private final String isbn;

    @CampoCsv(nombre = "Empleado", orden = 3, sensible = true)
    private final String empleado;

    @CampoCsv(nombre = "Fecha préstamo", orden = 4)
    private final LocalDate fechaPrestamo;

    @CampoCsv(nombre = "Fecha vencimiento", orden = 5)
    private final LocalDate fechaVencimiento;

    /** Instante EXACTO del registro: para auditoria se guarda Instant, no LocalDate. */
    private final Instant registradoEn;

    private LocalDate fechaDevolucion;              // null mientras siga prestado

    private final List<Incidencia> incidencias = new ArrayList<>();

    public Prestamo(String referencia, String isbn, String empleado,
                    LocalDate fechaPrestamo, LocalDate fechaVencimiento) {
        this.referencia = Objects.requireNonNull(referencia, "referencia");
        this.isbn = Objects.requireNonNull(isbn, "isbn");
        this.empleado = Objects.requireNonNull(empleado, "empleado");
        this.fechaPrestamo = Objects.requireNonNull(fechaPrestamo, "fechaPrestamo");
        this.fechaVencimiento = Objects.requireNonNull(fechaVencimiento, "fechaVencimiento");

        if (fechaVencimiento.isBefore(fechaPrestamo)) {
            throw new IllegalArgumentException(
                    "El vencimiento (" + fechaVencimiento + ") no puede ser anterior "
                    + "al préstamo (" + fechaPrestamo + ")");
        }
        this.registradoEn = Instant.now();
    }

    /** Fabrica que aplica el plazo estandar en dias HABILES. */
    public static Prestamo conPlazoEstandar(String referencia, String isbn,
                                            String empleado, LocalDate fechaPrestamo) {
        LocalDate vencimiento = fechaPrestamo.plus(PLAZO_ESTANDAR)
                .with(com.nexussoftware.bibliotech.servicio
                        .AjustadoresBiblioTech.siguienteDiaHabil());
        return new Prestamo(referencia, isbn, empleado, fechaPrestamo, vencimiento);
    }

    // --- Consultas dependientes del "hoy": SIEMPRE reciben la fecha ---

    /** Dias de retraso. 0 si aun no ha vencido. */
    public long diasDeRetraso(LocalDate hoy) {
        LocalDate referencia = fechaDevolucion != null ? fechaDevolucion : hoy;
        return Math.max(0, ChronoUnit.DAYS.between(fechaVencimiento, referencia));
    }

    public boolean tieneRetraso(LocalDate hoy) {
        return diasDeRetraso(hoy) > 0;
    }

    public boolean venceEn(LocalDate hoy, int dias) {
        LocalDate limite = hoy.plusDays(dias);
        return !fechaVencimiento.isBefore(hoy) && !fechaVencimiento.isAfter(limite);
    }

    /** Descomposicion humana del tiempo transcurrido. */
    public Period antiguedad(LocalDate hoy) {
        return Period.between(fechaPrestamo, hoy);
    }

    public void devolver(LocalDate cuando) {
        if (fechaDevolucion != null) {
            throw new IllegalStateException(
                    "El préstamo " + referencia + " ya se devolvió el " + fechaDevolucion);
        }
        if (cuando.isBefore(fechaPrestamo)) {
            throw new IllegalArgumentException("No se puede devolver antes de prestar");
        }
        this.fechaDevolucion = cuando;
    }

    // --- Serializacion: SIEMPRE ISO-8601 ---

    public String aLineaCsv() {
        return String.join(";",
                referencia, isbn, empleado,
                fechaPrestamo.format(ISO),
                fechaVencimiento.format(ISO),
                fechaDevolucion == null ? "" : fechaDevolucion.format(ISO),
                registradoEn.toString());          // Instant.toString() ya es ISO
    }

    public static Prestamo desdeLineaCsv(String linea) {
        String[] c = linea.split(";", -1);
        Prestamo p = new Prestamo(c[0], c[1], c[2],
                LocalDate.parse(c[3], ISO),
                LocalDate.parse(c[4], ISO));
        if (!c[5].isEmpty()) {
            p.devolver(LocalDate.parse(c[5], ISO));
        }
        return p;
    }

    // --- Getters ---
    @Override public String getId() { return referencia; }
    public String getIsbn() { return isbn; }
    public String getEmpleado() { return empleado; }
    public LocalDate getFechaPrestamo() { return fechaPrestamo; }
    public LocalDate getFechaVencimiento() { return fechaVencimiento; }
    public Optional<LocalDate> getFechaDevolucion() { return Optional.ofNullable(fechaDevolucion); }
    public Instant getRegistradoEn() { return registradoEn; }
    public List<Incidencia> getIncidencias() { return List.copyOf(incidencias); }

    public record Incidencia(String descripcion, Gravedad gravedad, LocalDate cuando) { }
}

El servicio de avisos, ahora con TemporalAdjusters:

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Prestamo;

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.*;
import java.util.stream.Collectors;

public class ServicioAvisos {

    private static final DateTimeFormatter LEGIBLE =
            DateTimeFormatter.ofPattern("EEEE d 'de' MMMM", new Locale("es", "ES"));

    private final Clock reloj;

    public ServicioAvisos(Clock reloj) {
        this.reloj = Objects.requireNonNull(reloj);
    }

    private LocalDate hoy() {
        return LocalDate.now(reloj);
    }

    /** Prestamos que vencen en los proximos N dias, agrupados por empleado. */
    public Map<String, List<Prestamo>> avisosDeVencimiento(List<Prestamo> prestamos, int dias) {
        LocalDate limite = hoy().plusDays(dias);
        return prestamos.stream()
                .filter(p -> p.getFechaDevolucion().isEmpty())
                .filter(p -> !p.getFechaVencimiento().isBefore(hoy()))
                .filter(p -> !p.getFechaVencimiento().isAfter(limite))
                .collect(Collectors.groupingBy(Prestamo::getEmpleado, TreeMap::new,
                         Collectors.toList()));
    }

    /** El aviso se envia el siguiente dia HABIL: nadie lee correos el domingo. */
    public LocalDate cuandoEnviarAviso(Prestamo prestamo, int diasDeAntelacion) {
        return prestamo.getFechaVencimiento()
                .minusDays(diasDeAntelacion)
                .with(AjustadoresBiblioTech.siguienteDiaHabil());
    }

    /** Fecha del informe mensual: ultimo viernes del mes. */
    public LocalDate fechaDelInformeMensual() {
        return hoy().with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY));
    }

    /** Fecha del inventario trimestral: primer lunes del proximo trimestre. */
    public LocalDate fechaDelInventarioTrimestral() {
        LocalDate hoy = hoy();
        int mesActual = hoy.getMonthValue();
        int primerMesDelProximoTrimestre = ((mesActual - 1) / 3 + 1) * 3 + 1;

        LocalDate base = primerMesDelProximoTrimestre > 12
                ? LocalDate.of(hoy.getYear() + 1, 1, 1)
                : LocalDate.of(hoy.getYear(), primerMesDelProximoTrimestre, 1);

        return base.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY));
    }

    public String redactarAviso(String empleado, List<Prestamo> prestamos) {
        StringBuilder sb = new StringBuilder();
        sb.append("Estimado/a ").append(empleado).append(":\n\n");
        sb.append("Le recordamos que tiene ").append(prestamos.size())
          .append(prestamos.size() == 1 ? " material pendiente" : " materiales pendientes")
          .append(" de devolución:\n\n");

        prestamos.stream()
                .sorted(Comparator.comparing(Prestamo::getFechaVencimiento))
                .forEach(p -> {
                    long dias = java.time.temporal.ChronoUnit.DAYS
                            .between(hoy(), p.getFechaVencimiento());
                    sb.append(String.format("  - %s   vence el %s (%s)%n",
                            p.getIsbn(),
                            p.getFechaVencimiento().format(LEGIBLE),
                            dias == 0 ? "hoy" : "en " + dias + (dias == 1 ? " día" : " días")));
                });

        sb.append("\nBiblioTech -- Nexus Software\n");
        sb.append("Generado el ").append(hoy().format(LEGIBLE)).append('\n');
        return sb.toString();
    }
}

Y la configuración fija la zona, en lugar de depender de la del sistema:

package com.nexussoftware.bibliotech.persistencia;

import java.time.*;
import java.util.Properties;

/**
 * Configuracion (07-07) con la zona horaria de la biblioteca EXPLICITA.
 * Depender de ZoneId.systemDefault() significa que el comportamiento
 * cambia al desplegar en un servidor configurado en UTC.
 */
public class ConfiguracionTemporal {

    private final ZoneId zona;
    private final Clock reloj;
    private final LocalTime aperturaBiblioteca;
    private final LocalTime cierreBiblioteca;
    private final Period plazoDePrestamo;

    public ConfiguracionTemporal(Properties propiedades) {
        this.zona = ZoneId.of(propiedades.getProperty("bibliotech.zona", "Europe/Madrid"));
        this.reloj = Clock.system(zona);
        this.aperturaBiblioteca = LocalTime.parse(
                propiedades.getProperty("bibliotech.apertura", "09:00"));
        this.cierreBiblioteca = LocalTime.parse(
                propiedades.getProperty("bibliotech.cierre", "20:00"));
        this.plazoDePrestamo = Period.parse(
                propiedades.getProperty("bibliotech.plazo", "P21D"));
    }

    public ZoneId zona() { return zona; }
    public Clock reloj() { return reloj; }
    public Period plazoDePrestamo() { return plazoDePrestamo; }

    public boolean estaAbierta(LocalDateTime cuando) {
        DayOfWeek dia = cuando.getDayOfWeek();
        if (dia == DayOfWeek.SATURDAY || dia == DayOfWeek.SUNDAY) {
            return false;
        }
        LocalTime hora = cuando.toLocalTime();
        return !hora.isBefore(aperturaBiblioteca) && hora.isBefore(cierreBiblioteca);
    }

    public boolean estaAbiertaAhora() {
        return estaAbierta(LocalDateTime.now(reloj));
    }
}
# bibliotech.properties
bibliotech.zona=Europe/Madrid
bibliotech.apertura=09:00
bibliotech.cierre=20:00
bibliotech.plazo=P21D

Fíjate en Period.parse("P21D"): el formato ISO-8601 de duraciones permite configurar el plazo desde un fichero sin inventar ningún formato propio. P1M sería un mes, P2W dos semanas.

Tabla de qué cambió en cada clase

Clase Antes Ahora
Prestamo int diaPrestamo, int diaVencimiento LocalDate fechaPrestamo, LocalDate fechaVencimiento, Optional<LocalDate> fechaDevolucion, Instant registradoEn
Prestamo.Incidencia String descripcion, Gravedad Añade LocalDate cuando
CalculadoraMultas Resta de enteros ChronoUnit.DAYS.between + Clock inyectado
ServicioAvisos Comparaciones de enteros TemporalAdjusters para día hábil, LocalDate para rangos
AjustadoresBiblioTech No existía Días hábiles, festivos con MonthDay, datesUntil
EscritorCsv / LectorCsv Números sin significado ISO-8601 con DateTimeFormatter.ISO_LOCAL_DATE
Configuracion Sin zona ZoneId explícito, Clock, LocalTime de apertura, Period de plazo
RegistroOperaciones System.currentTimeMillis() Instant
InventarioDeRespaldos FileTime opaco FileTime.toInstant().atZone(...)
SalaReuniones Franjas como enteros LocalTime de inicio y fin, Duration
Reserva Día como entero LocalDateTime de inicio + Duration

Y el resultado en el CSV:

Referencia;ISBN;Empleado;Fecha préstamo;Fecha vencimiento;Fecha devolución;Registrado en
PR-2026-0041;978-0000000001;Marta Ruiz;2026-07-15;2026-08-05;;2026-07-15T09:14:22.481Z
PR-2026-0042;978-0000000003;Diego Alonso;2026-07-06;2026-07-27;2026-08-03;2026-07-06T11:02:17.903Z

Cualquier sistema del mundo entiende ese fichero. Antes contenía los números 196 y 217, que solo significaban algo dentro de BiblioTech.

Errores Comunes y Consejos

1. Ignorar el valor devuelto. fecha.plusDays(21); no hace nada: los objetos son inmutables. Hay que asignar el resultado. Los IDE avisan; hazles caso.

2. Usar Date, Calendar o SimpleDateFormat en código nuevo. No hay ninguna razón para hacerlo desde 2014. Y SimpleDateFormat compartido entre hilos corrompe datos en silencio.

3. Guardar LocalDateTime cuando querías un instante. Funciona hasta que el servidor cambia de zona o entra un usuario de otro país, y entonces los datos ya guardados son irrecuperables. Guarda Instant.

4. Confundir MM con mm en los patrones. MM es el mes, mm los minutos. "dd/mm/yyyy" pone los minutos donde va el mes.

5. Usar YYYY en lugar de yyyy. Y es el año basado en semanas, y el 31 de diciembre de 2026 se formatea como 2027. Es un bug que aparece cada Nochevieja.

6. Depender del Locale o de la zona por defecto. Cambian entre tu máquina y el servidor. Sé explícito: new Locale("es", "ES"), ZoneId.of("Europe/Madrid").

7. Llamar a LocalDate.now() dentro de la lógica de negocio. Hace la clase imposible de probar de forma determinista. Inyecta un Clock.

8. Confundir Duration con Period. Duration.ofDays(1) son 86.400 segundos; Period.ofDays(1) es "el día siguiente". En la noche del cambio horario difieren en una hora.

9. Esperar que plusMonths sea reversible. 31 de enero + 1 mes − 1 mes = 28 de enero. El ajuste al último día válido es correcto pero no simétrico.

10. Usar abreviaturas de zona de tres letras. CST es ambiguo entre tres zonas distintas. Usa siempre Region/Ciudad.

11. Esperar que ChronoUnit.MONTHS.between redondee. Trunca hacia cero: del 5 de agosto al 4 de septiembre hay 0 meses.

12. Suponer que la hora local siempre existe y es única. Dos veces al año hay una hora que no existe y otra que ocurre dos veces.

13. Guardar el desfase en lugar de la zona. +02:00 no sabe que en invierno será +01:00. Para eventos futuros, guarda el ZoneId.

Consejo 1: usa el tipo más restrictivo que sirva. Si solo hay fecha, LocalDate. Añadir hora y zona "por si acaso" introduce ambigüedad y errores. YearMonth para informes mensuales, MonthDay para festivos anuales.

Consejo 2: ISO-8601 para persistir, formato localizado para mostrar. Y no mezcles las dos cosas: el CSV lleva 2026-08-05, la pantalla del usuario lleva miércoles, 5 de agosto de 2026.

Consejo 3: declara los DateTimeFormatter como static final. Son inmutables, seguros entre hilos y su construcción tiene coste (10-07). Uno por formato, reutilizado siempre.

Consejo 4: en los métodos que dependen de "hoy", pasa la fecha como parámetro. diasDeRetraso(LocalDate hoy) es probable; diasDeRetraso() que llama internamente a now() no lo es. Es la misma idea que el Clock, aplicada al nivel del método.

Consejo 5: valida los rangos en el constructor. Que el vencimiento no sea anterior al préstamo, que la devolución no sea anterior al préstamo. Un objeto que nace incoherente propaga el error por toda la aplicación (03-04).

Consejo 6: aprovecha datesUntil. Devuelve un Stream<LocalDate> y conecta java.time con todo lo de 10-04: desde.datesUntil(hasta).filter(...).count().

Ejercicios

Ejercicio 1: calendario de la biblioteca

Escribe CalendarioBiblioTech con un Clock inyectado que ofrezca:

  1. boolean esHabil(LocalDate): no es sábado, domingo ni festivo (usa MonthDay para los festivos fijos y calcula la Semana Santa con el algoritmo de Butcher para los móviles).
  2. LocalDate siguienteHabil(LocalDate) y LocalDate sumarDiasHabiles(LocalDate, int).
  3. long diasHabilesEntre(LocalDate, LocalDate) usando datesUntil.
  4. Map<Month, Long> habilesPorMes(int año) con streams.
  5. List<LocalDate> festivosDe(int año) ordenados, distinguiendo fijos y móviles.
  6. String calendarioDelMes(YearMonth) que imprima el mes en formato calendario con los días hábiles y festivos marcados.

Prueba con 2026 y comprueba que la Semana Santa cae donde debe.

Ejercicio 2: reservas de sala con zonas horarias

Nexus Software tiene sedes en Madrid, Nueva York y Tokio. Modela un sistema de reservas de salas que funcione entre husos:

  1. record Reserva(String id, String sala, String empleado, ZonedDateTime inicio, Duration duracion).
  2. ZonedDateTime fin() derivado.
  3. boolean solapaCon(Reserva) que funcione aunque las reservas estén en zonas distintas (compara instantes).
  4. GestorReservas con Resultado<Reserva> reservar(...) (el Resultado<T> de 10-01) que rechace solapes, reservas fuera del horario laboral de la sede de la sala, y reservas en el pasado.
  5. String agendaPara(String empleado, ZoneId zonaDelEmpleado) que muestre todas sus reservas en su zona.
  6. Demuestra con código el caso de una reserva creada en Tokio que un empleado de Madrid ve el día anterior.
  7. Añade un caso que caiga en la noche del cambio de horario y explica el resultado.

Ejercicio 3: informe temporal de préstamos

Con Prestamo ya migrado a LocalDate, escribe InformeTemporal que, combinando java.time con los streams de 10-04, produzca:

  1. Préstamos por YearMonth, en orden cronológico.
  2. Duración media real de los préstamos devueltos, en días, usando ChronoUnit.
  3. Distribución por día de la semana del préstamo (DayOfWeek), con histograma.
  4. Los 5 préstamos con más retraso a fecha del Clock inyectado.
  5. Préstamos que vencen esta semana (de lunes a domingo de la semana actual), usando TemporalAdjusters.
  6. Un "mapa de calor" por mes y día de la semana: Map<Month, Map<DayOfWeek, Long>>.
  7. Tasa de devolución puntual (devueltos antes o el día del vencimiento) por trimestre.

Todo con un Clock fijo para que los resultados sean reproducibles.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.servicio;

import java.time.*;
import java.time.format.TextStyle;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

/**
 * Calendario laboral de la biblioteca de Nexus Software.
 * Reloj inyectado: probable de forma determinista.
 */
public class CalendarioBiblioTech {

    private static final Locale ES = new Locale("es", "ES");

    /** Festivos de fecha FIJA: MonthDay, porque se repiten cada año. */
    private static final Map<MonthDay, String> FESTIVOS_FIJOS = Map.ofEntries(
            Map.entry(MonthDay.of(1, 1),   "Año Nuevo"),
            Map.entry(MonthDay.of(1, 6),   "Epifanía"),
            Map.entry(MonthDay.of(5, 1),   "Día del Trabajo"),
            Map.entry(MonthDay.of(8, 15),  "Asunción"),
            Map.entry(MonthDay.of(10, 12), "Fiesta Nacional"),
            Map.entry(MonthDay.of(11, 1),  "Todos los Santos"),
            Map.entry(MonthDay.of(12, 6),  "Constitución"),
            Map.entry(MonthDay.of(12, 8),  "Inmaculada"),
            Map.entry(MonthDay.of(12, 25), "Navidad"));

    private final Clock reloj;

    /** Cache de festivos moviles por año: el calculo no cambia nunca. */
    private final Map<Integer, Map<LocalDate, String>> movilesPorAno = new HashMap<>();

    public CalendarioBiblioTech(Clock reloj) {
        this.reloj = Objects.requireNonNull(reloj, "reloj");
    }

    public LocalDate hoy() {
        return LocalDate.now(reloj);
    }

    // ------------------------------------------------------------------
    // 1. Dias habiles y festivos
    // ------------------------------------------------------------------

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

    public Optional<String> nombreDelFestivo(LocalDate fecha) {
        String fijo = FESTIVOS_FIJOS.get(MonthDay.from(fecha));
        if (fijo != null) {
            return Optional.of(fijo);
        }
        return Optional.ofNullable(festivosMoviles(fecha.getYear()).get(fecha));
    }

    public boolean esFestivo(LocalDate fecha) {
        return nombreDelFestivo(fecha).isPresent();
    }

    public boolean esHabil(LocalDate fecha) {
        return !esFinDeSemana(fecha) && !esFestivo(fecha);
    }

    /**
     * Algoritmo de Butcher (o de Meeus/Jones/Butcher) para el domingo de Pascua
     * en el calendario gregoriano. A partir de el se derivan los demas.
     */
    private Map<LocalDate, String> festivosMoviles(int ano) {
        return movilesPorAno.computeIfAbsent(ano, y -> {
            LocalDate pascua = domingoDePascua(y);
            Map<LocalDate, String> moviles = new LinkedHashMap<>();
            moviles.put(pascua.minusDays(3), "Jueves Santo");
            moviles.put(pascua.minusDays(2), "Viernes Santo");
            moviles.put(pascua.plusDays(1),  "Lunes de Pascua");
            return moviles;
        });
    }

    public static LocalDate domingoDePascua(int ano) {
        int a = ano % 19;
        int b = ano / 100;
        int c = ano % 100;
        int d = b / 4;
        int e = b % 4;
        int f = (b + 8) / 25;
        int g = (b - f + 1) / 3;
        int h = (19 * a + b - d - g + 15) % 30;
        int i = c / 4;
        int k = c % 4;
        int l = (32 + 2 * e + 2 * i - h - k) % 7;
        int m = (a + 11 * h + 22 * l) / 451;
        int mes = (h + l - 7 * m + 114) / 31;
        int dia = ((h + l - 7 * m + 114) % 31) + 1;
        return LocalDate.of(ano, mes, dia);
    }

    // ------------------------------------------------------------------
    // 2. Navegacion por dias habiles
    // ------------------------------------------------------------------

    public LocalDate siguienteHabil(LocalDate desde) {
        LocalDate fecha = desde;
        while (!esHabil(fecha)) {
            fecha = fecha.plusDays(1);
        }
        return fecha;
    }

    public LocalDate sumarDiasHabiles(LocalDate desde, int dias) {
        if (dias < 0) {
            throw new IllegalArgumentException("Días negativos: " + dias);
        }
        LocalDate fecha = desde;
        int restantes = dias;
        while (restantes > 0) {
            fecha = fecha.plusDays(1);
            if (esHabil(fecha)) {
                restantes--;
            }
        }
        return fecha;
    }

    // ------------------------------------------------------------------
    // 3-5. Consultas agregadas con streams (10-04)
    // ------------------------------------------------------------------

    /** datesUntil (Java 9) devuelve un Stream<LocalDate>: el puente con 10-04. */
    public long diasHabilesEntre(LocalDate desde, LocalDate hastaExclusivo) {
        return desde.datesUntil(hastaExclusivo)
                    .filter(this::esHabil)
                    .count();
    }

    public Map<Month, Long> habilesPorMes(int ano) {
        return LocalDate.of(ano, 1, 1)
                .datesUntil(LocalDate.of(ano + 1, 1, 1))
                .filter(this::esHabil)
                .collect(Collectors.groupingBy(LocalDate::getMonth,
                         () -> new EnumMap<>(Month.class),
                         Collectors.counting()));
    }

    public List<Map.Entry<LocalDate, String>> festivosDe(int ano) {
        return LocalDate.of(ano, 1, 1)
                .datesUntil(LocalDate.of(ano + 1, 1, 1))
                .filter(this::esFestivo)
                .map(f -> Map.entry(f, nombreDelFestivo(f).orElse("?")))
                .sorted(Map.Entry.comparingByKey())
                .toList();
    }

    // ------------------------------------------------------------------
    // 6. Calendario visual
    // ------------------------------------------------------------------

    public String calendarioDelMes(YearMonth mes) {
        StringBuilder sb = new StringBuilder();

        String titulo = mes.getMonth().getDisplayName(TextStyle.FULL, ES).toUpperCase()
                + " " + mes.getYear();
        sb.append(String.format("%s%n", centrar(titulo, 28)));
        sb.append(" Lu  Ma  Mi  Ju  Vi  Sá  Do\n");

        LocalDate primero = mes.atDay(1);
        // getValue(): 1 = lunes ... 7 = domingo (ISO)
        int huecoInicial = primero.getDayOfWeek().getValue() - 1;
        sb.append("    ".repeat(huecoInicial));

        for (int dia = 1; dia <= mes.lengthOfMonth(); dia++) {
            LocalDate fecha = mes.atDay(dia);

            String marca;
            if (esFestivo(fecha))            marca = "*";     // festivo
            else if (esFinDeSemana(fecha))   marca = ".";     // fin de semana
            else                             marca = " ";     // habil

            sb.append(String.format("%3d%s", dia, marca));

            if (fecha.getDayOfWeek() == DayOfWeek.SUNDAY) {
                sb.append('\n');
            }
        }
        if (mes.atEndOfMonth().getDayOfWeek() != DayOfWeek.SUNDAY) {
            sb.append('\n');
        }

        long habiles = diasHabilesEntre(mes.atDay(1), mes.plusMonths(1).atDay(1));
        sb.append(String.format("%nDías hábiles: %d de %d   (* festivo, . fin de semana)%n",
                habiles, mes.lengthOfMonth()));

        festivosDe(mes.getYear()).stream()
                .filter(e -> YearMonth.from(e.getKey()).equals(mes))
                .forEach(e -> sb.append(String.format("  %s  %s%n",
                        e.getKey().getDayOfMonth(), e.getValue())));

        return sb.toString();
    }

    private static String centrar(String texto, int ancho) {
        int izquierda = Math.max(0, (ancho - texto.length()) / 2);
        return " ".repeat(izquierda) + texto;
    }
}

Prueba:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.servicio.CalendarioBiblioTech;

import java.time.*;

public class PruebaCalendario {

    public static void main(String[] args) {

        Clock fijo = Clock.fixed(Instant.parse("2026-08-05T10:00:00Z"),
                                 ZoneId.of("Europe/Madrid"));
        CalendarioBiblioTech calendario = new CalendarioBiblioTech(fijo);

        System.out.println("Hoy: " + calendario.hoy() + " ("
                + calendario.hoy().getDayOfWeek() + "), hábil: "
                + calendario.esHabil(calendario.hoy()));

        System.out.println("\nPascua 2026: " + CalendarioBiblioTech.domingoDePascua(2026));
        System.out.println("Pascua 2027: " + CalendarioBiblioTech.domingoDePascua(2027));

        System.out.println("\n--- Festivos de 2026 ---");
        calendario.festivosDe(2026).forEach(e ->
                System.out.printf("  %s  %-18s (%s)%n",
                        e.getKey(), e.getValue(), e.getKey().getDayOfWeek()));

        System.out.println("\n--- Días hábiles por mes en 2026 ---");
        calendario.habilesPorMes(2026).forEach((mes, n) ->
                System.out.printf("  %-12s %2d %s%n", mes, n, "#".repeat(n.intValue())));

        System.out.println("\n" + calendario.calendarioDelMes(YearMonth.of(2026, 4)));
        System.out.println(calendario.calendarioDelMes(YearMonth.of(2026, 8)));

        System.out.println("--- Navegación ---");
        LocalDate viernes = LocalDate.of(2026, 8, 14);
        System.out.println("Viernes 14/08:            " + viernes);
        System.out.println("Siguiente hábil desde 15: "
                + calendario.siguienteHabil(viernes.plusDays(1)));
        System.out.println("+10 días hábiles:         "
                + calendario.sumarDiasHabiles(viernes, 10));
    }
}
Hoy: 2026-08-05 (WEDNESDAY), hábil: true

Pascua 2026: 2026-04-05
Pascua 2027: 2027-03-28

--- Festivos de 2026 ---
  2026-01-01  Año Nuevo          (THURSDAY)
  2026-01-06  Epifanía           (TUESDAY)
  2026-04-02  Jueves Santo       (THURSDAY)
  2026-04-03  Viernes Santo      (FRIDAY)
  2026-04-06  Lunes de Pascua    (MONDAY)
  2026-05-01  Día del Trabajo    (FRIDAY)
  2026-08-15  Asunción           (SATURDAY)
  2026-10-12  Fiesta Nacional    (MONDAY)
  2026-11-01  Todos los Santos   (SUNDAY)
  2026-12-06  Constitución       (SUNDAY)
  2026-12-08  Inmaculada         (TUESDAY)
  2026-12-25  Navidad            (FRIDAY)

--- Días hábiles por mes en 2026 ---
  JANUARY      20 ####################
  FEBRUARY     20 ####################
  MARCH        22 ######################
  APRIL        19 ###################
  MAY          20 ####################
  JUNE         22 ######################
  JULY         23 #######################
  AUGUST       21 #####################
  SEPTEMBER    22 ######################
  OCTOBER      21 #####################
  NOVEMBER     21 #####################
  DECEMBER     21 #####################

            ABRIL 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

Días hábiles: 19 de 30   (* festivo, . fin de semana)
  2  Jueves Santo
  3  Viernes Santo
  6  Lunes de Pascua

           AGOSTO 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

Días hábiles: 21 de 31   (* festivo, . fin de semana)
  15  Asunción

--- Navegación ---
Viernes 14/08:            2026-08-14
Siguiente hábil desde 15: 2026-08-17
+10 días hábiles:         2026-08-31

Comentarios.

MonthDay es exactamente el tipo correcto para festivos fijos. Un LocalDate obligaría a repetir la lista cada año o a construirla dinámicamente. MonthDay.of(12, 25) dice "el 25 de diciembre, cualquier año", que es lo que un festivo fijo es.

La caché de festivos móviles importa más de lo que parece. habilesPorMes(2026) llama a esHabil 365 veces, y cada llamada necesita los festivos móviles del año. Sin computeIfAbsent, el algoritmo de Butcher se ejecutaría 365 veces para el mismo resultado.

datesUntil conecta las dos lecciones. LocalDate.datesUntil(fin) devuelve un Stream<LocalDate>, y a partir de ahí todo 10-04 se aplica: filter, groupingBy, counting. Los 12 meses con sus días hábiles salen en una sola expresión.

El 15 de agosto de 2026 es sábado y festivo a la vez, lo que ilustra por qué esHabil tiene que comprobar las dos cosas y por qué el conteo de agosto da 21 y no 20.

Solución 2

package com.nexussoftware.bibliotech.dominio;

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Objects;

/**
 * Reserva de sala con zona horaria explicita.
 * ZonedDateTime porque hay que respetar el horario laboral LOCAL
 * de cada sede y el cambio de hora.
 */
public record Reserva(String id, String sala, String empleado,
                      ZonedDateTime inicio, Duration duracion) {

    private static final DateTimeFormatter LEGIBLE =
            DateTimeFormatter.ofPattern("EEE dd/MM HH:mm", new Locale("es", "ES"));

    public Reserva {
        Objects.requireNonNull(id, "id");
        Objects.requireNonNull(sala, "sala");
        Objects.requireNonNull(empleado, "empleado");
        Objects.requireNonNull(inicio, "inicio");
        Objects.requireNonNull(duracion, "duracion");
        if (duracion.isNegative() || duracion.isZero()) {
            throw new IllegalArgumentException("La duración debe ser positiva: " + duracion);
        }
        if (duracion.toHours() > 8) {
            throw new IllegalArgumentException("Una reserva no puede durar más de 8 horas");
        }
    }

    public ZonedDateTime fin() {
        return inicio.plus(duracion);
    }

    /**
     * Compara INSTANTES, no horas locales.
     * Asi funciona aunque las dos reservas esten en zonas distintas
     * y aunque haya cambio de horario de por medio.
     */
    public boolean solapaCon(Reserva otra) {
        if (!sala.equals(otra.sala)) {
            return false;
        }
        Instant miInicio = inicio.toInstant();
        Instant miFin = fin().toInstant();
        Instant suInicio = otra.inicio.toInstant();
        Instant suFin = otra.fin().toInstant();

        return miInicio.isBefore(suFin) && suInicio.isBefore(miFin);
    }

    /** La misma reserva vista desde otra zona: MISMO instante, otro reloj. */
    public Reserva vistaDesde(ZoneId zona) {
        return new Reserva(id, sala, empleado, inicio.withZoneSameInstant(zona), duracion);
    }

    public String describir() {
        return String.format("%-8s %-12s %-14s %s -- %s (%dh%02dm) [%s]",
                id, sala, empleado,
                inicio.format(LEGIBLE), fin().format(LEGIBLE),
                duracion.toHours(), duracion.toMinutesPart(),
                inicio.getZone());
    }
}
package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Reserva;

import java.time.*;
import java.util.*;
import java.util.stream.Collectors;

public class GestorReservas {

    /** Sede de cada sala: define su horario laboral local. */
    private static final Map<String, ZoneId> SEDE_DE_LA_SALA = Map.of(
            "Madrid-A",   ZoneId.of("Europe/Madrid"),
            "Madrid-B",   ZoneId.of("Europe/Madrid"),
            "NuevaYork-1", ZoneId.of("America/New_York"),
            "Tokio-1",    ZoneId.of("Asia/Tokyo"));

    private static final LocalTime APERTURA = LocalTime.of(8, 0);
    private static final LocalTime CIERRE   = LocalTime.of(20, 0);

    private final List<Reserva> reservas = new ArrayList<>();
    private final Clock reloj;

    public GestorReservas(Clock reloj) {
        this.reloj = Objects.requireNonNull(reloj);
    }

    public Resultado<Reserva> reservar(String id, String sala, String empleado,
                                       ZonedDateTime inicio, Duration duracion) {

        ZoneId sede = SEDE_DE_LA_SALA.get(sala);
        if (sede == null) {
            return Resultado.fallo("No existe la sala '" + sala + "'");
        }

        // 1. No se reserva en el pasado: comparacion de INSTANTES
        if (inicio.toInstant().isBefore(Instant.now(reloj))) {
            return Resultado.fallo("No se puede reservar en el pasado (" + inicio + ")");
        }

        Reserva candidata;
        try {
            candidata = new Reserva(id, sala, empleado, inicio, duracion);
        } catch (IllegalArgumentException e) {
            return Resultado.fallo(e.getMessage());
        }

        // 2. Horario laboral EN LA ZONA DE LA SALA, no en la de quien reserva
        ZonedDateTime inicioLocal = inicio.withZoneSameInstant(sede);
        ZonedDateTime finLocal = candidata.fin().withZoneSameInstant(sede);

        if (esFinDeSemana(inicioLocal)) {
            return Resultado.fallo(String.format(
                    "La sala %s está cerrada: %s es %s en %s",
                    sala, inicioLocal.toLocalDate(), inicioLocal.getDayOfWeek(), sede));
        }
        if (inicioLocal.toLocalTime().isBefore(APERTURA)
                || finLocal.toLocalTime().isAfter(CIERRE)
                || !inicioLocal.toLocalDate().equals(finLocal.toLocalDate())) {
            return Resultado.fallo(String.format(
                    "Fuera del horario de %s (%s-%s hora local): %s a %s",
                    sala, APERTURA, CIERRE,
                    inicioLocal.toLocalTime(), finLocal.toLocalTime()));
        }

        // 3. Solapes, comparando instantes
        Optional<Reserva> conflicto = reservas.stream()
                .filter(candidata::solapaCon)
                .findFirst();

        if (conflicto.isPresent()) {
            return Resultado.fallo("Solapa con " + conflicto.get().id()
                    + " (" + conflicto.get().empleado() + ")");
        }

        reservas.add(candidata);
        return Resultado.exito(candidata);
    }

    private boolean esFinDeSemana(ZonedDateTime z) {
        DayOfWeek d = z.getDayOfWeek();
        return d == DayOfWeek.SATURDAY || d == DayOfWeek.SUNDAY;
    }

    /** Agenda de un empleado en SU zona horaria. */
    public String agendaPara(String empleado, ZoneId zonaDelEmpleado) {
        List<Reserva> suyas = reservas.stream()
                .filter(r -> r.empleado().equals(empleado))
                .map(r -> r.vistaDesde(zonaDelEmpleado))
                .sorted(Comparator.comparing(r -> r.inicio().toInstant()))
                .toList();

        if (suyas.isEmpty()) {
            return empleado + " no tiene reservas.";
        }

        StringBuilder sb = new StringBuilder();
        sb.append("Agenda de ").append(empleado)
          .append(" (horario de ").append(zonaDelEmpleado).append(")\n");

        suyas.stream()
             .collect(Collectors.groupingBy(r -> r.inicio().toLocalDate(),
                      TreeMap::new, Collectors.toList()))
             .forEach((dia, delDia) -> {
                 sb.append("  ").append(dia).append(":\n");
                 delDia.forEach(r -> sb.append("    ").append(r.describir()).append('\n'));
             });

        return sb.toString();
    }
}

La demostración:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.servicio.*;

import java.time.*;

public class PruebaReservasMultizona {

    public static void main(String[] args) {

        ZoneId madrid = ZoneId.of("Europe/Madrid");
        ZoneId tokio  = ZoneId.of("Asia/Tokyo");
        ZoneId nyc    = ZoneId.of("America/New_York");

        Clock reloj = Clock.fixed(Instant.parse("2026-08-03T06:00:00Z"), madrid);
        GestorReservas gestor = new GestorReservas(reloj);

        System.out.println("Ahora: " + ZonedDateTime.now(reloj));
        System.out.println();

        // 1. Reserva normal en Madrid
        mostrar(gestor.reservar("R-001", "Madrid-A", "Marta Ruiz",
                ZonedDateTime.of(2026, 8, 5, 10, 0, 0, 0, madrid), Duration.ofHours(2)));

        // 2. Solape en la misma sala
        mostrar(gestor.reservar("R-002", "Madrid-A", "Diego Alonso",
                ZonedDateTime.of(2026, 8, 5, 11, 0, 0, 0, madrid), Duration.ofHours(1)));

        // 3. Otra sala a la misma hora: SIN solape
        mostrar(gestor.reservar("R-003", "Madrid-B", "Diego Alonso",
                ZonedDateTime.of(2026, 8, 5, 11, 0, 0, 0, madrid), Duration.ofHours(1)));

        // 4. Sala de Tokio reservada DESDE MADRID a las 3 de la mañana hora de Madrid
        //    = 10:00 hora de Tokio: horario laboral CORRECTO alli
        mostrar(gestor.reservar("R-004", "Tokio-1", "Nuria Vidal",
                ZonedDateTime.of(2026, 8, 6, 3, 0, 0, 0, madrid), Duration.ofHours(1)));

        // 5. Sala de Tokio a las 10:00 hora de MADRID = 17:00 en Tokio: aun abierto
        mostrar(gestor.reservar("R-005", "Tokio-1", "Marta Ruiz",
                ZonedDateTime.of(2026, 8, 6, 10, 0, 0, 0, madrid), Duration.ofHours(1)));

        // 6. Sala de Tokio a las 16:00 hora de Madrid = 23:00 en Tokio: CERRADO
        mostrar(gestor.reservar("R-006", "Tokio-1", "Diego Alonso",
                ZonedDateTime.of(2026, 8, 6, 16, 0, 0, 0, madrid), Duration.ofHours(1)));

        // 7. Nueva York en sabado hora local
        mostrar(gestor.reservar("R-007", "NuevaYork-1", "Marta Ruiz",
                ZonedDateTime.of(2026, 8, 8, 15, 0, 0, 0, madrid), Duration.ofHours(1)));

        // 8. En el pasado
        mostrar(gestor.reservar("R-008", "Madrid-A", "Nuria Vidal",
                ZonedDateTime.of(2026, 8, 1, 10, 0, 0, 0, madrid), Duration.ofHours(1)));

        System.out.println();
        System.out.println(gestor.agendaPara("Marta Ruiz", madrid));
        System.out.println(gestor.agendaPara("Marta Ruiz", tokio));

        // --- El caso del cambio de horario ---
        System.out.println("--- Reserva la noche del cambio de horario ---");
        ZonedDateTime nocheDelCambio = ZonedDateTime.of(2026, 10, 25, 1, 30, 0, 0, madrid);
        System.out.println("Inicio:  " + nocheDelCambio);
        System.out.println("+2 horas (Duration): " + nocheDelCambio.plus(Duration.ofHours(2)));
        System.out.println("Desfases válidos a las 02:30: "
                + madrid.getRules().getValidOffsets(LocalDateTime.of(2026, 10, 25, 2, 30)));
    }

    private static void mostrar(Resultado<com.nexussoftware.bibliotech.dominio.Reserva> r) {
        if (r.esExito()) {
            System.out.println("OK      " + r.valor().describir());
        } else {
            System.out.println("RECHAZO " + r.error());
        }
    }
}
Ahora: 2026-08-03T08:00+02:00[Europe/Madrid]

OK      R-001    Madrid-A     Marta Ruiz     mié 05/08 10:00 -- mié 05/08 12:00 (2h00m) [Europe/Madrid]
RECHAZO Solapa con R-001 (Marta Ruiz)
OK      R-003    Madrid-B     Diego Alonso   mié 05/08 11:00 -- mié 05/08 12:00 (1h00m) [Europe/Madrid]
OK      R-004    Tokio-1      Nuria Vidal    jue 06/08 03:00 -- jue 06/08 04:00 (1h00m) [Europe/Madrid]
OK      R-005    Tokio-1      Marta Ruiz     jue 06/08 10:00 -- jue 06/08 11:00 (1h00m) [Europe/Madrid]
RECHAZO Fuera del horario de Tokio-1 (08:00-20:00 hora local): 23:00 a 00:00
RECHAZO La sala NuevaYork-1 está cerrada: 2026-08-08 es SATURDAY en America/New_York
RECHAZO No se puede reservar en el pasado (2026-08-01T10:00+02:00[Europe/Madrid])

Agenda de Marta Ruiz (horario de Europe/Madrid)
  2026-08-05:
    R-001    Madrid-A     Marta Ruiz     mié 05/08 10:00 -- mié 05/08 12:00 (2h00m) [Europe/Madrid]
  2026-08-06:
    R-005    Tokio-1      Marta Ruiz     jue 06/08 10:00 -- jue 06/08 11:00 (1h00m) [Europe/Madrid]

Agenda de Marta Ruiz (horario de Asia/Tokyo)
  2026-08-05:
    R-001    Madrid-A     Marta Ruiz     mié 05/08 17:00 -- mié 05/08 19:00 (2h00m) [Asia/Tokyo]
  2026-08-06:
    R-005    Tokio-1      Marta Ruiz     jue 06/08 17:00 -- jue 06/08 18:00 (1h00m) [Asia/Tokyo]

--- Reserva la noche del cambio de horario ---
Inicio:  2026-10-25T01:30+02:00[Europe/Madrid]
+2 horas (Duration): 2026-10-25T02:30+01:00[Europe/Madrid]
Desfases válidos a las 02:30: [+02:00, +01:00]

Comentarios. Cinco puntos.

El caso R-004 es el corazón del ejercicio. Nuria reserva la sala de Tokio a las 3 de la madrugada hora de Madrid. Suena absurdo hasta que ves que son las 10 de la mañana en Tokio, plenamente dentro del horario laboral de esa sede. El sistema acepta porque valida el horario en la zona de la sala, no en la de quien reserva.

R-006 es el contrario. Diego intenta reservar Tokio a las 16:00 hora de Madrid, que le parece perfectamente razonable, y son las 23:00 en Tokio. Rechazado, con un mensaje que explica exactamente por qué.

R-007 demuestra que el día de la semana también depende de la zona. El 8 de agosto es sábado en las dos zonas, pero podría no serlo cerca de medianoche. Comparar el DayOfWeek de la zona equivocada produce rechazos y aceptaciones erróneas.

Las dos agendas de Marta muestran la misma reserva con distinto reloj. withZoneSameInstant no cambia cuándo ocurre la reunión: cambia qué marcará su reloj. Las 10:00 de Madrid son las 17:00 de Tokio, y las dos son correctas.

Y el caso del cambio de horario: sumar Duration.ofHours(2) a la 1:30 da las 2:30 con desfase +01:00, no +02:00. Han pasado exactamente dos horas de tiempo real, pero el reloj solo avanzó una hora aparente porque a las 3:00 retrocedió a las 2:00. getValidOffsets devolviendo dos desfases es la señal de que esa hora local es ambigua.

Solución 3

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Prestamo;

import java.time.*;
import java.time.format.TextStyle;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
import java.util.*;
import java.util.stream.Collectors;

/**
 * Informe temporal de prestamos.
 * java.time (10-05) + streams (10-04) + Clock inyectado.
 */
public class InformeTemporal {

    private static final Locale ES = new Locale("es", "ES");

    private final Clock reloj;
    private final List<Prestamo> prestamos;

    public InformeTemporal(Clock reloj, List<Prestamo> prestamos) {
        this.reloj = Objects.requireNonNull(reloj);
        this.prestamos = List.copyOf(prestamos);
    }

    private LocalDate hoy() {
        return LocalDate.now(reloj);
    }

    // --- 1. Prestamos por mes ---
    public Map<YearMonth, Long> porMes() {
        return prestamos.stream()
                .collect(Collectors.groupingBy(
                        p -> YearMonth.from(p.getFechaPrestamo()),
                        TreeMap::new,                      // orden cronologico
                        Collectors.counting()));
    }

    // --- 2. Duracion media real de los devueltos ---
    public OptionalDouble duracionMediaDevueltos() {
        return prestamos.stream()
                .filter(p -> p.getFechaDevolucion().isPresent())
                .mapToLong(p -> ChronoUnit.DAYS.between(
                        p.getFechaPrestamo(), p.getFechaDevolucion().orElseThrow()))
                .average();
    }

    // --- 3. Distribucion por dia de la semana ---
    public Map<DayOfWeek, Long> porDiaDeLaSemana() {
        return prestamos.stream()
                .collect(Collectors.groupingBy(
                        p -> p.getFechaPrestamo().getDayOfWeek(),
                        () -> new EnumMap<>(DayOfWeek.class),
                        Collectors.counting()));
    }

    // --- 4. Los 5 con mas retraso ---
    public List<Prestamo> masRetrasados(int n) {
        LocalDate hoy = hoy();
        return prestamos.stream()
                .filter(p -> p.getFechaDevolucion().isEmpty())
                .filter(p -> p.tieneRetraso(hoy))
                .sorted(Comparator.comparingLong((Prestamo p) -> p.diasDeRetraso(hoy)).reversed())
                .limit(n)
                .toList();
    }

    // --- 5. Vencen esta semana (lunes a domingo) ---
    public List<Prestamo> vencenEstaSemana() {
        LocalDate lunes = hoy().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
        LocalDate domingo = hoy().with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY));

        return prestamos.stream()
                .filter(p -> p.getFechaDevolucion().isEmpty())
                .filter(p -> !p.getFechaVencimiento().isBefore(lunes))
                .filter(p -> !p.getFechaVencimiento().isAfter(domingo))
                .sorted(Comparator.comparing(Prestamo::getFechaVencimiento))
                .toList();
    }

    // --- 6. Mapa de calor mes x dia de la semana ---
    public Map<Month, Map<DayOfWeek, Long>> mapaDeCalor() {
        return prestamos.stream()
                .collect(Collectors.groupingBy(
                        p -> p.getFechaPrestamo().getMonth(),
                        () -> new EnumMap<>(Month.class),
                        Collectors.groupingBy(
                                p -> p.getFechaPrestamo().getDayOfWeek(),
                                () -> new EnumMap<>(DayOfWeek.class),
                                Collectors.counting())));
    }

    // --- 7. Puntualidad por trimestre ---
    public record Puntualidad(long puntuales, long totales) {
        public double porcentaje() {
            return totales == 0 ? 0.0 : puntuales * 100.0 / totales;
        }
    }

    public Map<String, Puntualidad> puntualidadPorTrimestre() {
        return prestamos.stream()
                .filter(p -> p.getFechaDevolucion().isPresent())
                .collect(Collectors.groupingBy(
                        this::trimestreDe,
                        TreeMap::new,
                        Collectors.collectingAndThen(
                                Collectors.toList(),
                                lista -> new Puntualidad(
                                        lista.stream().filter(this::fueDevueltoAtiempo).count(),
                                        lista.size()))));
    }

    private String trimestreDe(Prestamo p) {
        LocalDate f = p.getFechaDevolucion().orElseThrow();
        return f.getYear() + "-T" + ((f.getMonthValue() - 1) / 3 + 1);
    }

    private boolean fueDevueltoAtiempo(Prestamo p) {
        return !p.getFechaDevolucion().orElseThrow().isAfter(p.getFechaVencimiento());
    }

    // ------------------------------------------------------------------

    public String generar() {
        StringBuilder sb = new StringBuilder();
        LocalDate hoy = hoy();

        sb.append("=".repeat(64)).append('\n');
        sb.append("  INFORME TEMPORAL DE PRÉSTAMOS -- ")
          .append(hoy.format(java.time.format.DateTimeFormatter
                  .ofPattern("d 'de' MMMM 'de' yyyy", ES))).append('\n');
        sb.append("=".repeat(64)).append('\n');

        sb.append("\n1. PRÉSTAMOS POR MES\n");
        porMes().forEach((mes, n) -> sb.append(String.format("   %s  %3d  %s%n",
                mes, n, "▇".repeat(n.intValue()))));

        sb.append("\n2. DURACIÓN MEDIA DE LOS DEVUELTOS\n");
        duracionMediaDevueltos().ifPresentOrElse(
                d -> sb.append(String.format("   %.1f días%n", d)),
                () -> sb.append("   (aún no hay devoluciones)\n"));

        sb.append("\n3. DÍA DE LA SEMANA DEL PRÉSTAMO\n");
        Map<DayOfWeek, Long> porDia = porDiaDeLaSemana();
        long maximo = porDia.values().stream().mapToLong(Long::longValue).max().orElse(1);
        Arrays.stream(DayOfWeek.values()).forEach(d -> {
            long n = porDia.getOrDefault(d, 0L);
            sb.append(String.format("   %-11s %3d %s%n",
                    d.getDisplayName(TextStyle.FULL, ES), n,
                    "▇".repeat((int) (n * 24 / maximo))));
        });

        sb.append("\n4. LOS 5 CON MÁS RETRASO\n");
        List<Prestamo> retrasados = masRetrasados(5);
        if (retrasados.isEmpty()) {
            sb.append("   (ningún préstamo con retraso)\n");
        } else {
            retrasados.forEach(p -> sb.append(String.format("   %-14s %-14s venció %s  %3d días%n",
                    p.getId(), p.getEmpleado(), p.getFechaVencimiento(), p.diasDeRetraso(hoy))));
        }

        sb.append("\n5. VENCEN ESTA SEMANA\n");
        List<Prestamo> semana = vencenEstaSemana();
        if (semana.isEmpty()) {
            sb.append("   (ninguno)\n");
        } else {
            semana.forEach(p -> sb.append(String.format("   %-14s %-14s %s (%s)%n",
                    p.getId(), p.getEmpleado(), p.getFechaVencimiento(),
                    p.getFechaVencimiento().getDayOfWeek().getDisplayName(TextStyle.FULL, ES))));
        }

        sb.append("\n6. MAPA DE CALOR (mes x día)\n");
        sb.append("            Lu  Ma  Mi  Ju  Vi  Sá  Do\n");
        mapaDeCalor().forEach((mes, porDiaDelMes) -> {
            sb.append(String.format("   %-9s",
                    mes.getDisplayName(TextStyle.SHORT, ES)));
            Arrays.stream(DayOfWeek.values()).forEach(d ->
                    sb.append(String.format("%4d", porDiaDelMes.getOrDefault(d, 0L))));
            sb.append('\n');
        });

        sb.append("\n7. PUNTUALIDAD POR TRIMESTRE\n");
        puntualidadPorTrimestre().forEach((trimestre, p) ->
                sb.append(String.format("   %-9s %3d/%-3d  %5.1f %%  %s%n",
                        trimestre, p.puntuales(), p.totales(), p.porcentaje(),
                        "▇".repeat((int) (p.porcentaje() / 5)))));

        return sb.toString();
    }
}
================================================================
  INFORME TEMPORAL DE PRÉSTAMOS -- 5 de agosto de 2026
================================================================

1. PRÉSTAMOS POR MES
   2026-04    7  ▇▇▇▇▇▇▇
   2026-05   11  ▇▇▇▇▇▇▇▇▇▇▇
   2026-06    9  ▇▇▇▇▇▇▇▇▇
   2026-07   14  ▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   2026-08    4  ▇▇▇▇

2. DURACIÓN MEDIA DE LOS DEVUELTOS
   18,4 días

3. DÍA DE LA SEMANA DEL PRÉSTAMO
   lunes        12 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   martes        8 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   miércoles     9 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   jueves        7 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   viernes       9 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   sábado        0
   domingo       0

4. LOS 5 CON MÁS RETRASO
   PR-2026-0012   Diego Alonso   venció 2026-05-22   75 días
   PR-2026-0019   Marta Ruiz     venció 2026-06-15   51 días
   PR-2026-0027   Nuria Vidal    venció 2026-07-10   26 días
   PR-2026-0031   Diego Alonso   venció 2026-07-24   12 días
   PR-2026-0035   Marta Ruiz     venció 2026-08-03    2 días

5. VENCEN ESTA SEMANA
   PR-2026-0035   Marta Ruiz     2026-08-03 (lunes)
   PR-2026-0038   Nuria Vidal    2026-08-06 (jueves)
   PR-2026-0040   Diego Alonso   2026-08-07 (viernes)

6. MAPA DE CALOR (mes x día)
            Lu  Ma  Mi  Ju  Vi  Sá  Do
   abr         2   1   2   1   1   0   0
   may         3   2   2   2   2   0   0
   jun         2   2   2   1   2   0   0
   jul         4   2   2   2   4   0   0
   ago         1   1   1   1   0   0   0

7. PUNTUALIDAD POR TRIMESTRE
   2026-T2   14/18   77,8 %  ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   2026-T3    9/13   69,2 %  ▇▇▇▇▇▇▇▇▇▇▇▇▇

Comentarios.

YearMonth como clave del TreeMap da el orden cronológico gratis. YearMonth implementa Comparable, así que 2026-04 va antes que 2026-05 sin ningún comparador. Con cadenas "abril 2026" habría que ordenar a mano y saldría alfabético.

EnumMap para DayOfWeek y Month (05-05) no es un detalle: es la estructura correcta para claves de enum, más rápida y con las claves ya en orden natural. Un HashMap<DayOfWeek, Long> daría los días en orden arbitrario.

Ni un solo préstamo en sábado o domingo, lo que confirma que los datos de prueba respetan el calendario laboral y valida de paso el CalendarioBiblioTech del ejercicio 1.

El Clock fijo hace el informe reproducible. "Vencen esta semana" depende de qué día es hoy, y con LocalDate.now() el informe daría resultados distintos cada día — imposible de verificar en una prueba automática. Con Clock.fixed, el informe del 5 de agosto de 2026 es siempre el mismo. Esto es lo que hará posible probar BiblioTech en 11-04.

Y previousOrSame(MONDAY) con nextOrSame(SUNDAY) delimitan la semana ISO exactamente, sin aritmética con getDayOfWeek().getValue() propensa a errores de contorno. Cuando hoy es lunes, previousOrSame devuelve hoy, que es lo correcto.

Conclusión

Las fechas de BiblioTech han dejado de ser enteros.

Sabes por qué existió el problema: Date y Calendar fueron un desastre de diseño con meses desde cero, años desde 1900, objetos mutables que se modifican al pasarlos a un método, ausencia total de separación entre "una fecha" y "un instante", y —el bug más costoso— SimpleDateFormat compartido entre hilos, que no lanza excepciones sino que produce datos silenciosamente incorrectos bajo carga, en producción, de forma intermitente, tras funcionar perfectamente en desarrollo.

Y sabes por qué java.time es distinta: inmutable (todo método devuelve una copia), segura entre hilos por consecuencia directa de lo anterior, fluida (los métodos se encadenan) y explícita sobre la zona horaria — con el nombre de la clase declarando exactamente qué llevas: Local... sin zona, Zoned.../Offset... con ella, Instant como punto en la línea temporal.

Conoces las clases núcleo y el criterio para elegir: LocalDate para fechas de calendario (la mayoría de campos de negocio), LocalTime para horas del día, LocalDateTime para ambas sin zona, ZonedDateTime para momentos que hay que mostrar o programar respetando el horario de verano, OffsetDateTime para intercambio, Instant para registrar y medir, y las pequeñas que se olvidan y son perfectas para su caso: YearMonth para informes mensuales, MonthDay para festivos anuales, Year, Month y DayOfWeek como enum con nombres localizables.

Tienes clarísima la regla de oro: guarda Instant, muestra ZonedDateTime. Un LocalDateTime no identifica ningún momento —las 9:30 del 5 de agosto ocurren a horas distintas en Madrid y en Tokio— y guardarlo en base de datos funciona hasta el día en que el servidor cambia de zona o entra un usuario de otro país, y entonces los datos ya guardados son irrecuperables porque nadie sabe en qué zona se escribieron.

Creas con now, of (que valida: LocalDate.of(2026, 2, 30) lanza DateTimeException en lugar de convertirse silenciosamente en el 2 de marzo) y parse. Consultas con getYear, getDayOfWeek, lengthOfMonth, isLeapYear. Manipulas con plusX, minusX y withX, recordando asignar el resultado porque los objetos son inmutables y fecha.plusDays(21); no hace absolutamente nada. Y sabes que la aritmética de meses ajusta al último día válido y por tanto no es reversible: 31 de enero más un mes menos un mes es el 28 de enero.

Distingues Duration de Period: tiempo de máquina frente a tiempo humano, segundos frente a años-meses-días — y has visto la diferencia real en la noche del cambio horario, donde Period.ofDays(1) mantiene la hora y Duration.ofDays(1) la desplaza sesenta minutos. Calculas con ChronoUnit.between, sabiendo que trunca hacia cero y que del 5 de agosto al 4 de septiembre hay cero meses. Y entiendes por qué "un mes" no es un número fijo de días: son 28, 29, 30 o 31, y Period.ofMonths(1) no se puede convertir a días sin una fecha de referencia.

Usas TemporalAdjusters para lo que a mano es incómodo y propenso a errores —firstDayOfMonth, lastInMonth(FRIDAY), next frente a nextOrSame— y escribes los tuyos, porque TemporalAdjuster es una interfaz funcional y una lambda basta: el siguiente día hábil de BiblioTech saltando fines de semana y festivos.

Manejas zonas horarias con ZoneId (que conoce las reglas históricas y futuras de la base de datos IANA) frente a ZoneOffset (que es solo un desfase fijo), con identificadores Region/Ciudad y nunca abreviaturas ambiguas de tres letras. Sabes que withZoneSameInstant y withZoneSameLocal hacen cosas opuestas y cuál quieres el 99 % de las veces. Y conoces los dos casos peligrosos del horario de verano, demostrados: la hora que no existe —donde atZone desplaza silenciosamente las 2:30 a las 3:30— y la hora que ocurre dos veces —donde getValidOffsets devuelve dos desfases, dos instantes reales separados por una hora comparten el mismo reloj de pared, y una tarea programada puede ejecutarse dos veces o un registro posterior parecer anterior—.

Formateas y parseas con DateTimeFormatter, que es inmutable y seguro entre hilos y por tanto se declara static final sin ningún riesgo. Conoces las letras de patrón y las dos trampas clásicas: MM es el mes y mm los minutos, y YYYY es el año basado en semanas que convierte el 31 de diciembre de 2026 en 2027. Usas ISO-8601 para persistir e intercambiar y formatos localizados con Locale explícito para mostrar, sin depender jamás del Locale por defecto de la JVM. Y capturas DateTimeParseException —no comprobada— devolviendo Optional para la entrada del usuario.

Conviertes desde y hacia la API antigua (Date.from/toInstant, GregorianCalendar.toZonedDateTime, java.sql.Date.toLocalDate) con Instant como puente universal, y has saldado la deuda de 07-06: FileTime.toInstant() y FileTime.from(Instant) convierten las marcas de tiempo de los ficheros en fechas de verdad.

Y sobre todo: usas Clock. Sabes que LocalDate.now() dentro de la lógica de negocio hace la clase imposible de probar de forma determinista, y que inyectar un ClockClock.fixed para congelar el tiempo, Clock.offset para viajar al futuro— convierte una prueba que depende del día en que se ejecuta en una que dará el mismo resultado dentro de cinco años. Los casos límite de CalculadoraMultas —vence hoy, un día de retraso, exactamente los días de cortesía, el tope de 20 euros— se comprueban ahora de forma explícita y reproducible.

BiblioTech ha migrado por completo. Prestamo tiene LocalDate fechaPrestamo, LocalDate fechaVencimiento, Optional<LocalDate> fechaDevolucion y un Instant registradoEn para la auditoría, con validación de rangos en el constructor. CalculadoraMultas usa ChronoUnit.DAYS.between y recibe su Clock. ServicioAvisos calcula con TemporalAdjusters para que los avisos caigan en día hábil y las fechas de informe sean el último viernes del mes. AjustadoresBiblioTech conoce los festivos con MonthDay y cuenta días hábiles con datesUntil. El CSV lleva ISO-8601 que cualquier sistema del mundo entiende, en lugar de los números 196 y 217 que solo significaban algo dentro de BiblioTech. Y Configuracion fija la zona horaria explícitamente, en lugar de heredar la del servidor y comportarse distinto al desplegar.

Y ahora mira el código que has escrito en esta lección. Está lleno de switch con ->, de record, de var, de List.of y Map.ofEntries, de instanceof con patrón, de String.repeat, de Files.readString. Todo eso lo has estado usando desde el módulo 1 sin que nadie te dijera de dónde salió, en qué versión apareció ni qué problema resolvía. Y hay más que aún no has visto: bloques de texto que harían legible el JSON del módulo 11, clases selladas que permitirían al compilador verificar que has cubierto todos los tipos de Material, pattern matching en switch que sustituiría cadenas enteras de if (x instanceof ...), y los hilos virtuales que —como anticipaste en 09-03— cambian por completo el cálculo de "un hilo por conexión" del ServidorCatalogo.

En 10-06, Java 9 y Más Allá, se ordena todo eso. Verás el calendario semestral de versiones y qué significan las LTS, el sistema de módulos que explica por qué la reflexión de 10-03 dejó de poder abrirlo todo, las incorporaciones de API y de sintaxis versión por versión con el criterio de qué usar hoy, las clases selladas combinadas con record para modelar tipos algebraicos que hacen exhaustivo un switch sin default, el pattern matching de Java 21 con patrones de registro y guardas when, y los hilos virtuales, con la refactorización del ServidorCatalogo que hace innecesario su pool acotado — junto con el "pinning" de synchronized y qué es lo que no resuelven.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados