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
- Por qué existió el problema:
DateyCalendar - El bug clásico:
SimpleDateFormatcompartido - Los principios de diseño de
java.time - Las clases núcleo y cómo elegir
Instantfrente aLocalDateTime: la regla de oro- Creación:
now,of,parse - Consulta: obtener partes y propiedades
- Manipulación: métodos que devuelven copias
- Comparación
Durationfrente aPeriodChronoUnit.between- Ajustadores temporales:
TemporalAdjusters - Zonas horarias:
ZoneIdyZoneOffset - El horario de verano y sus dos casos peligrosos
- Formateo y parseo con
DateTimeFormatter Locale: nombres de mes y día en español- Interoperabilidad con la API antigua y con
FileTime Clock: la fuente de tiempo inyectable- BiblioTech: la migración completa
- Errores Comunes y Consejos
- Ejercicios
- Por qué existió el problema:
Date y Calendar
Date y CalendarPara 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) |
- El bug clásico:
SimpleDateFormat compartido
SimpleDateFormat compartidoEste 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:07Por 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 finales 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.
- Los principios de diseño de
java.time
java.timeCuatro 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-04De 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.
- 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:
- ¿Solo hay fecha, sin hora? →
LocalDate. Es el caso de la mayoría de campos de negocio: vencimientos, fechas de alta, plazos. - ¿Es un momento que hay que registrar o comparar entre sistemas? →
Instant. - ¿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()); // 365YearMonth 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.
Instant frente a LocalDateTime: la regla de oro
Instant frente a LocalDateTime: la regla de oroEsta 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())); // PT7HLa regla de oro
Guarda
Instant(oOffsetDateTimeen UTC). MuestraZonedDateTime. 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.
- Creación:
now, of, parse
now, of, parseLas 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:
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:
- 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()); // 123000000Month 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"))); // agostoY 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
- 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:59El 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-05Los 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 siLos 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-28java.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
- 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 localRegla: para saber si dos momentos son el mismo momento, usa isEqual o compara los Instant. equals en ZonedDateTime casi nunca es lo que quieres.
Duration frente a Period
Duration frente a PeriodDos 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íasPor 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))); // 29Un 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)); // 28La 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.
ChronoUnit.between
ChronoUnit.betweenPara "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)); // 0El 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))); // 1Y el signo indica la dirección:
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)); // 9045ChronoUnit.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íasUsa ChronoUnit para calcular ("lleva 8 días de retraso") y Period para presentar ("hace 1 año, 2 meses y 5 días").
- Ajustadores temporales:
TemporalAdjusters
TemporalAdjustersUn 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-11La 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: 20El 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.
- Zonas horarias:
ZoneId y ZoneOffset
ZoneId y ZoneOffsetZoneId 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 600La 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? falseLos 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:00Un solo Instant, seis relojes de pared distintos. Eso es exactamente la regla de oro del apartado 5 en acción.
- 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: PT1HatZone 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
LocalDateTimeda un orden incorrecto; ordenar porInstantda 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.
- Formateo y parseo con
DateTimeFormatter
DateTimeFormatterDateTimeFormatter 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)); // 20260805ISO-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 2026Las 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:30Un solo formateador que acepta las dos formas. Es exactamente lo que necesita un importador de CSV que recibe datos de varias fuentes.
Locale: nombres de mes y día en español
Locale: nombres de mes y día en españolSin 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 agostNunca 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 2026Y 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
- Interoperabilidad con la API antigua y con
FileTime
FileTimeEn 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: 1FileTime.toInstant() y FileTime.from(Instant) son el puente en las dos direcciones. La deuda de 07-06 queda saldada.
Clock: la fuente de tiempo inyectable
Clock: la fuente de tiempo inyectableEste 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:
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.
- 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=P21DFí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.903ZCualquier 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:
boolean esHabil(LocalDate): no es sábado, domingo ni festivo (usaMonthDaypara los festivos fijos y calcula la Semana Santa con el algoritmo de Butcher para los móviles).LocalDate siguienteHabil(LocalDate)yLocalDate sumarDiasHabiles(LocalDate, int).long diasHabilesEntre(LocalDate, LocalDate)usandodatesUntil.Map<Month, Long> habilesPorMes(int año)con streams.List<LocalDate> festivosDe(int año)ordenados, distinguiendo fijos y móviles.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:
record Reserva(String id, String sala, String empleado, ZonedDateTime inicio, Duration duracion).ZonedDateTime fin()derivado.boolean solapaCon(Reserva)que funcione aunque las reservas estén en zonas distintas (compara instantes).GestorReservasconResultado<Reserva> reservar(...)(elResultado<T>de 10-01) que rechace solapes, reservas fuera del horario laboral de la sede de la sala, y reservas en el pasado.String agendaPara(String empleado, ZoneId zonaDelEmpleado)que muestre todas sus reservas en su zona.- Demuestra con código el caso de una reserva creada en Tokio que un empleado de Madrid ve el día anterior.
- 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:
- Préstamos por
YearMonth, en orden cronológico. - Duración media real de los préstamos devueltos, en días, usando
ChronoUnit. - Distribución por día de la semana del préstamo (
DayOfWeek), con histograma. - Los 5 préstamos con más retraso a fecha del
Clockinyectado. - Préstamos que vencen esta semana (de lunes a domingo de la semana actual), usando
TemporalAdjusters. - Un "mapa de calor" por mes y día de la semana:
Map<Month, Map<DayOfWeek, Long>>. - 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-31Comentarios.
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 Clock —Clock.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
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
