En tancar la lliçó anterior va quedar assenyalat un deute que arrossegues des del mòdul 3: les dates de BiblioTech són enters.
Prestec guarda un int diaPrestec i un int diaVenciment. CalculadoraMultes resta aquests dos enters i en diu "dies de retard". L'histograma del tauler calcula el dia de la setmana amb diaPrestec % 7, que funciona per pura casualitat. Ningú no pot respondre a "aquest préstec venç d'aquí a un mes?" sense decidir arbitràriament si un mes són trenta dies o trenta-un. El CSV exporta números que fora de BiblioTech no signifiquen absolutament res. I les marques de temps dels fitxers, que a 07-06 vas mostrar com a FileTime sense poder fer-hi res, continuen esperant.
Tot això s'acaba en aquesta lliçó.
I convé saber per què va arribar tan tard: durant gairebé vint anys, Java no va tenir una API de dates decent. java.util.Date (1996) i java.util.Calendar (1997) van ser dos dels errors de disseny més famosos de la biblioteca estàndard, fins al punt que la pràctica recomanada al món empresarial era no fer-les servir i recórrer a una biblioteca externa, Joda-Time. El 2014, Java 8 va incorporar java.time, dissenyada pel mateix autor de Joda-Time (Stephen Colebourne) sobre les lliçons apreses, i estandarditzada com a JSR-310.
El resultat és una API que fa bé el que l'anterior feia malament: és immutable, segura entre fils, fluida i explícita sobre si hi ha zona horària o no. Val la pena aprendre-la amb calma, perquè les dates són el domini on més errors subtils es cometen: horaris d'estiu, anys de traspàs, zones horàries, mesos de durada variable i usuaris en fusos diferents.
En acabar, Prestec tindrà LocalDate dataPrestec i LocalDate dataVenciment, les multes es calcularan amb ChronoUnit.DAYS.between, els avisos cauran en dia hàbil gràcies a TemporalAdjusters, i el CSV portarà dates ISO-8601 que qualsevol sistema del món entén.
Contingut
- Per què va existir el problema:
DateiCalendar - L'error clàssic:
SimpleDateFormatcompartit - Els principis de disseny de
java.time - Les classes nucli i com triar
Instantenfront deLocalDateTime: la regla d'or- Creació:
now,of,parse - Consulta: obtenir parts i propietats
- Manipulació: mètodes que retornen còpies
- Comparació
Durationenfront dePeriodChronoUnit.between- Ajustadors temporals:
TemporalAdjusters - Zones horàries:
ZoneIdiZoneOffset - L'horari d'estiu i els seus dos casos perillosos
- Format i anàlisi amb
DateTimeFormatter Locale: noms de mes i dia en català- Interoperabilitat amb l'API antiga i amb
FileTime Clock: la font de temps injectable- BiblioTech: la migració completa
- Errors Comuns i Consells
- Exercicis
- Per què va existir el problema:
Date i Calendar
Date i CalendarPer apreciar java.time convé veure de què s'escapa. Aquest codi fa servir l'API antiga:
import java.util.Calendar;
import java.util.Date;
public class ApiAntiga {
public static void main(String[] args) {
// Crear el 15 de marc de 2026
Calendar c = Calendar.getInstance();
c.set(2026, 2, 15); // 2 es MARC! Els mesos comencen a 0
Date data = c.getTime();
System.out.println(data);
// Sumar 30 dies: MUTA l objecte
c.add(Calendar.DAY_OF_MONTH, 30);
System.out.println(c.getTime());
// I l objecte original ha canviat tambe, perque es el mateix
Date altraReferencia = data;
System.out.println(altraReferencia);
}
}Els defectes, un per un:
| Defecte | Conseqüència |
|---|---|
| Mesos des de 0 | c.set(2026, 2, 15) és març, no febrer. Error d'un mes en producció |
Anys des de 1900 a Date |
new Date(126, 2, 15) és 2026. Absurd |
| Mutables | Passar un Date a un mètode el pot retornar modificat |
| No segures entre fils | Calendar i SimpleDateFormat compartits corrompen dades |
| Sense separació de conceptes | Un Date no és una data: és un instant. No hi ha manera d'expressar "el 15 de març" sense hora ni zona |
| API confusa | Date té mètodes obsolets des de Java 1.1 que continuen allà |
Date no representa una data |
Internament és un long de mil·lisegons des del 1970 UTC |
| Aritmètica pobra | Sumar un mes requereix Calendar, i el resultat depèn de l'estat de l'objecte |
Comparació directa:
| Tasca | API antiga | java.time |
|---|---|---|
| Avui | new Date() |
LocalDate.now() |
| 15 de març de 2026 | cal.set(2026, 2, 15) |
LocalDate.of(2026, 3, 15) |
| Sumar 30 dies | cal.add(Calendar.DAY_OF_MONTH, 30) (muta) |
data.plusDays(30) (retorna còpia) |
| És anterior? | d1.before(d2) |
d1.isBefore(d2) |
| Dies entre dues dates | Aritmètica manual amb mil·lisegons | ChronoUnit.DAYS.between(d1, d2) |
| Formatar | new SimpleDateFormat("dd/MM/yyyy") (insegur) |
DateTimeFormatter.ofPattern("dd/MM/yyyy") (segur) |
| Només l'hora | Impossible sense paranys | LocalTime.of(9, 30) |
| Només el mes i l'any | Impossible | YearMonth.of(2026, 3) |
- L'error clàssic:
SimpleDateFormat compartit
SimpleDateFormat compartitAquest mereix el seu propi apartat perquè és, literalment, un dels errors més freqüents de la història de Java empresarial.
package com.nexussoftware.bibliotech;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.*;
public class BugDeSimpleDateFormat {
// Sembla raonable: un formatador reutilitzable, per no crear ne un per crida
private static final SimpleDateFormat FORMAT = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss");
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(10);
Date data = new Date();
for (int i = 0; i < 20; i++) {
pool.submit(() -> {
try {
System.out.println(Thread.currentThread().getName()
+ " -> " + FORMAT.format(data));
} catch (Exception e) {
System.out.println("EXCEPCIO: " + e);
}
});
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
}
}Sortida (variable a cada execució):
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 <-- any incorrecte
EXCEPCIO: java.lang.NumberFormatException: multiple points
pool-1-thread-4 -> 05/08/2026 14:32:07Per què passa: SimpleDateFormat guarda estat intern mutable (un Calendar i una memòria intermèdia) mentre formata. Si dos fils formaten alhora, es trepitgen aquest estat. El resultat no és una excepció clara sinó dades silenciosament incorrectes, que és infinitament pitjor.
I és un error traïdor perquè:
- No falla en desenvolupament, on un sol fil formata cada vegada.
- Falla sota càrrega, en producció, de manera intermitent.
- Produeix dades dolentes, no errors: una data amb l'any equivocat es guarda a la base de dades i ningú no se n'assabenta.
- Sembla una bona pràctica. Declarar el formatador com a
static finalés exactament el que un faria per no crear objectes innecessaris.
La solució amb l'API antiga era crear-ne un per crida (malbaratant memòria) o fer servir ThreadLocal (que introdueix un altre problema, com veuràs a 10-07). Amb java.time:
// DateTimeFormatter es IMMUTABLE i SEGUR entre fils.
// Compartir lo com a constant static final es el CORRECTE.
private static final DateTimeFormatter FORMAT =
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss");Sense cap risc, amb qualsevol nombre de fils.
- Els principis de disseny de
java.time
java.timeQuatre decisions expliquen tota l'API:
1. Immutabilitat. Cap objecte de java.time no canvia mai. Tots els mètodes "de modificació" retornen un objecte nou:
LocalDate avui = LocalDate.of(2026, 8, 5);
LocalDate futur = avui.plusDays(30);
System.out.println(avui); // 2026-08-05 sense canvis
System.out.println(futur); // 2026-09-04D'aquí se'n deriva la segona propietat automàticament:
2. Seguretat entre fils. Un objecte immutable es pot compartir entre qualsevol nombre de fils sense sincronització (08-04). LocalDate, DateTimeFormatter, ZoneId: tots segurs.
3. API fluida. Els mètodes s'encadenen perquè cadascun retorna un objecte nou:
LocalDateTime venciment = LocalDateTime.now()
.plusDays(21)
.withHour(18)
.withMinute(0)
.withSecond(0)
.withNano(0);4. Explícita sobre la zona horària. Aquesta és la decisió més important i la que més problemes evita. A l'API antiga, un Date era sempre un instant UTC que es mostrava a la zona per defecte de la JVM, i era impossible dir "el 15 de març" sense arrossegar una hora i una zona. A java.time, el nom de la classe et diu exactament què portes:
| Prefix | Significat |
|---|---|
Local... |
Sense zona horària. És una data o una hora "de calendari de paret" |
Zoned... / Offset... |
Amb zona horària o amb desfasament respecte a UTC |
Instant |
Un punt a la línia temporal, sense calendari ni zona |
No et podràs confondre mai, perquè el tipus ho declara.
- Les classes nucli i com triar
graph TD
A["Que necessites representar?"] --> B{"Importa la<br/>zona horaria?"}
B -->|"No: data de calendari"| C{"Data, hora<br/>o totes dues?"}
C -->|"Nomes data"| D["LocalDate<br/>2026-08-05"]
C -->|"Nomes hora"| E["LocalTime<br/>09:30"]
C -->|"Totes dues"| F["LocalDateTime<br/>2026-08-05T09:30"]
B -->|"Si: moment real"| G{"Per a persona<br/>o per a maquina?"}
G -->|"Persona: mostrar"| H["ZonedDateTime<br/>amb regles de DST"]
G -->|"Maquina: registrar"| I["Instant<br/>marca temporal UTC"]
G -->|"Intercanvi amb desfasament fix"| J["OffsetDateTime<br/>+02:00"]
| Classe | Representa | Exemple | Cas d'ús a BiblioTech |
|---|---|---|---|
LocalDate |
Data sense hora ni zona | 2026-08-05 |
Data de préstec, venciment, aniversari |
LocalTime |
Hora sense data ni zona | 09:30:00 |
Hora d'obertura de la biblioteca |
LocalDateTime |
Data i hora sense zona | 2026-08-05T09:30 |
Reserva de sala (interpretada a la zona local) |
ZonedDateTime |
Data, hora i zona completa | 2026-08-05T09:30+02:00[Europe/Madrid] |
Avís programat que ha de respectar l'horari d'estiu |
OffsetDateTime |
Data, hora i desfasament fix | 2026-08-05T09:30+02:00 |
Intercanvi amb API, columnes de base de dades |
Instant |
Punt a la línia temporal (UTC) | 2026-08-05T07:30:00Z |
Marca de registre, auditoria, mesurament |
Year |
Un any | 2026 |
Estadístiques anuals |
YearMonth |
Any i mes | 2026-08 |
Informe mensual de préstecs |
MonthDay |
Mes i dia, sense any | --12-25 |
Festius que es repeteixen cada any |
DayOfWeek |
Dia de la setmana (enum) |
WEDNESDAY |
Dies hàbils |
Month |
Mes (enum) |
AUGUST |
Estadístiques per mes |
Duration |
Quantitat de temps (màquina) | PT48H |
Durada d'una sessió |
Period |
Quantitat de temps (humà) | P21D |
Termini de préstec |
Com triar, en tres preguntes:
- Només hi ha data, sense hora? →
LocalDate. És el cas de la majoria de camps de negoci: venciments, dates d'alta, terminis. - És un moment que cal registrar o comparar entre sistemes? →
Instant. - Cal mostrar-lo a un usuari a la seva zona, o programar alguna cosa respectant l'horari d'estiu? →
ZonedDateTime.
Year, YearMonth i MonthDay són classes petites que s'obliden i són molt útils:
YearMonth agost = YearMonth.of(2026, 8);
System.out.println(agost.lengthOfMonth()); // 31
System.out.println(agost.atDay(15)); // 2026-08-15
System.out.println(agost.atEndOfMonth()); // 2026-08-31
MonthDay nadal = MonthDay.of(12, 25);
System.out.println(nadal.atYear(2026)); // 2026-12-25
Year any = Year.of(2026);
System.out.println(any.isLeap()); // false
System.out.println(any.length()); // 365YearMonth és exactament el tipus correcte per a "l'informe d'agost de 2026", i evita el pedaç de guardar el dia 1 en un LocalDate i recordar-se d'ignorar-lo.
Instant enfront de LocalDateTime: la regla d'or
Instant enfront de LocalDateTime: la regla d'orAquesta distinció causa més errors que cap altra, i mereix entendre's bé.
LocalDateTime no representa un moment concret. 2026-08-05T09:30 no identifica cap punt de la línia temporal: a Madrid passa a una hora, a Tòquio a una altra, i a Nova York a una altra. És un calendari de paret: "quan el rellotge marqui les 9:30 del 5 d'agost, siguis on siguis".
Instant sí que representa un moment concret. 2026-08-05T07:30:00Z és un punt únic i inequívoc a la història de l'univers. Tothom està d'acord en quan va passar; només discrepen en què marcava el seu rellotge de paret.
LocalDateTime local = LocalDateTime.of(2026, 8, 5, 9, 30);
// El MATEIX LocalDateTime en dues zones son dos INSTANTS diferents
ZonedDateTime aMadrid = local.atZone(ZoneId.of("Europe/Madrid"));
ZonedDateTime aToquio = local.atZone(ZoneId.of("Asia/Tokyo"));
System.out.println(aMadrid); // 2026-08-05T09:30+02:00[Europe/Madrid]
System.out.println(aToquio); // 2026-08-05T09:30+09:00[Asia/Tokyo]
System.out.println(aMadrid.toInstant()); // 2026-08-05T07:30:00Z
System.out.println(aToquio.toInstant()); // 2026-08-05T00:30:00Z
// Set hores de diferencia entre dues "mateixes" hores
System.out.println(Duration.between(aToquio.toInstant(), aMadrid.toInstant())); // PT7HLa regla d'or
Guarda
Instant(oOffsetDateTimeen UTC). MostraZonedDateTime. Emmagatzema moments absoluts i converteix-los a la zona de l'usuari només a la vora de l'aplicació, en presentar-los.
| Situació | Tipus correcte | Per què |
|---|---|---|
| Columna "creat_el" a base de dades | Instant / TIMESTAMP WITH TIME ZONE |
Inequívoc, comparable, ordenable |
| Marca de temps en un log | Instant |
Correlacionable entre servidors en fusos diferents |
| Mesurar quant triga alguna cosa | Instant + Duration |
Sense salts d'horari d'estiu |
| Data de venciment d'un préstec | LocalDate |
És una data de calendari, no un instant |
| Hora d'obertura de la biblioteca | LocalTime |
Les 9:00 són les 9:00 a la zona de la biblioteca |
| Mostrar "creat el ..." a l'usuari | ZonedDateTime a la seva zona |
Cadascú ho veu a la seva hora local |
| Programar un avís a les 8:00 del dilluns | ZonedDateTime |
Ha de respectar el canvi d'horari |
L'error clàssic és guardar LocalDateTime a la base de dades. Funciona perfectament mentre el servidor, la base de dades i tots els usuaris són a la mateixa zona. El dia que es desplega en un servidor en UTC o entra un usuari des d'un altre país, les hores es desplacen i les dades ja guardades són irrecuperables, perquè ningú no sap en quina zona es van escriure.
- Creació:
now, of, parse
now, of, parseLes tres vies, uniformes a totes les classes:
// --- now(): el moment actual ---
LocalDate avui = LocalDate.now();
LocalTime ara = LocalTime.now();
LocalDateTime araComplet = LocalDateTime.now();
ZonedDateTime aMadrid = ZonedDateTime.now(ZoneId.of("Europe/Madrid"));
Instant instant = Instant.now();
// --- of(): construir amb valors explicits ---
LocalDate data = LocalDate.of(2026, 8, 5);
LocalDate data2 = LocalDate.of(2026, Month.AUGUST, 5); // amb l enum: mes llegible
LocalTime hora = LocalTime.of(9, 30);
LocalTime horaAmbSegons = LocalTime.of(9, 30, 45);
LocalDateTime completa = LocalDateTime.of(2026, 8, 5, 9, 30);
LocalDateTime combinada = LocalDateTime.of(data, hora); // combinant
// --- parse(): des de text 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");
// --- Conversions entre tipus ---
LocalDateTime deData = data.atTime(9, 30);
LocalDateTime deData2 = data.atStartOfDay(); // 2026-08-05T00:00
LocalDate nomesData = completa.toLocalDate();
LocalTime nomesHora = completa.toLocalTime();
ZonedDateTime ambZona = completa.atZone(ZoneId.of("Europe/Madrid"));
Instant aInstant = ambZona.toInstant();of valida. Un valor impossible llança DateTimeException immediatament, no produeix una data estranya:
Compara-ho amb Calendar, que per defecte és "indulgent" i convertia el 30 de febrer en el 2 de març silenciosament.
Els mesos comencen a 1. LocalDate.of(2026, 8, 5) és agost. I per a màxima claredat existeix l'enum Month:
- Consulta: obtenir parts i propietats
LocalDate data = LocalDate.of(2026, 8, 5);
System.out.println(data.getYear()); // 2026
System.out.println(data.getMonthValue()); // 8
System.out.println(data.getMonth()); // AUGUST (enum Month)
System.out.println(data.getDayOfMonth()); // 5
System.out.println(data.getDayOfWeek()); // WEDNESDAY (enum DayOfWeek)
System.out.println(data.getDayOfYear()); // 217
System.out.println(data.lengthOfMonth()); // 31
System.out.println(data.lengthOfYear()); // 365
System.out.println(data.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 i DayOfWeek són enum (04-07), cosa que obre possibilitats:
DayOfWeek dia = data.getDayOfWeek();
// switch d expressio (02-03, i el veuras modern a 10-06)
String tipus = switch (dia) {
case SATURDAY, SUNDAY -> "cap de setmana";
default -> "dia laborable";
};
// Nom localitzat
System.out.println(dia.getDisplayName(TextStyle.FULL, new Locale("ca", "ES"))); // dimecres
// Aritmetica de dies
System.out.println(dia.plus(3)); // SATURDAY
System.out.println(dia.getValue()); // 3 (1=dilluns ... 7=diumenge, ISO)
Month mes = data.getMonth();
System.out.println(mes.length(false)); // 31 (false = no de traspas)
System.out.println(mes.getDisplayName(TextStyle.FULL, new Locale("ca", "ES"))); // agostI per a casos genèrics, get(TemporalField):
System.out.println(data.get(ChronoField.DAY_OF_WEEK)); // 3
System.out.println(data.get(ChronoField.ALIGNED_WEEK_OF_YEAR)); // 31
// Numero de setmana ISO (la que fan servir els sistemes europeus)
System.out.println(data.get(WeekFields.ISO.weekOfWeekBasedYear())); // 32
- Manipulació: mètodes que retornen còpies
Tres famílies de mètodes, tots retornant objectes nous:
| Prefix | Què fa | Exemple |
|---|---|---|
plusX |
Suma | plusDays(30), plusMonths(1), plusYears(1) |
minusX |
Resta | minusWeeks(2), minusHours(3) |
withX |
Reemplaça un component | withDayOfMonth(1), withYear(2027) |
LocalDate avui = LocalDate.of(2026, 8, 5);
System.out.println(avui.plusDays(21)); // 2026-08-26
System.out.println(avui.plusWeeks(3)); // 2026-08-26
System.out.println(avui.plusMonths(1)); // 2026-09-05
System.out.println(avui.minusYears(1)); // 2025-08-05
System.out.println(avui.withDayOfMonth(1)); // 2026-08-01
System.out.println(avui.withMonth(12)); // 2026-12-05
System.out.println(avui.withYear(2030)); // 2030-08-05
// Encadenament fluid
LocalDateTime venciment = LocalDateTime.of(2026, 8, 5, 14, 23, 51)
.plusDays(21)
.withHour(23)
.withMinute(59)
.withSecond(59);
System.out.println(venciment); // 2026-08-26T23:59:59L'error clàssic: ignorar el valor retornat
LocalDate venciment = LocalDate.of(2026, 8, 5);
venciment.plusDays(21); // NO FA RES!
System.out.println(venciment); // 2026-08-05Els objectes són immutables: plusDays no pot canviar venciment. Retorna una data nova que, si no l'assignes, es descarta. És exactament el mateix error que amb String:
String s = "hola";
s.toUpperCase(); // no fa res
System.out.println(s); // hola
s = s.toUpperCase(); // aixi siEls IDE avisen d'això ("result of method is ignored"), i convé fer-los cas.
L'aritmètica de mesos no és trivial
LocalDate fiDeGener = LocalDate.of(2026, 1, 31);
System.out.println(fiDeGener.plusMonths(1)); // 2026-02-28 no el 31!
System.out.println(fiDeGener.plusMonths(3)); // 2026-04-30 no el 31!
// I no es reversible
System.out.println(fiDeGener.plusMonths(1).minusMonths(1)); // 2026-01-28java.time ajusta a l'últim dia vàlid del mes destí quan el dia no existeix. És la solució raonable, però implica que sumar i restar un mes no sempre retorna al punt de partida. Si la teva lògica de negoci en depèn, fes explícit el criteri al codi, no el donis per suposat.
Els anys de traspàs fan el mateix:
LocalDate deTraspas = LocalDate.of(2024, 2, 29);
System.out.println(deTraspas.plusYears(1)); // 2025-02-28
- Comparació
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
// Tambe son Comparable (05-09), aixi que s ordenen
List<LocalDate> dates = new ArrayList<>(List.of(b, a));
Collections.sort(dates);
System.out.println(dates); // [2026-08-05, 2026-08-26]
// I amb streams (10-04)
Optional<LocalDate> mesRecent = dates.stream().max(Comparator.naturalOrder());isEqual enfront d'equals: per a LocalDate són equivalents. La diferència importa a ZonedDateTime i ChronoLocalDate, on isEqual compara l'instant i equals compara tots els camps inclosa 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 MATEIX instant
System.out.println(madrid.equals(londres)); // false: zona i hora local diferentsRegla: per saber si dos moments són el mateix moment, fes servir isEqual o compara els Instant. equals a ZonedDateTime gairebé mai no és el que vols.
Duration enfront de Period
Duration enfront de PeriodDues classes per expressar "una quantitat de temps", i la diferència és conceptual, no tècnica.
Duration |
Period |
|
|---|---|---|
| Concepte | Temps de màquina | Temps humà |
| Unitats | Segons i nanosegons | Anys, mesos i dies |
| S'aplica a | Instant, LocalTime, LocalDateTime |
LocalDate, LocalDateTime |
| "Un dia" és | Exactament 86.400 segons | Un dia de calendari (pot tenir 23 o 25 hores) |
| Format ISO | PT48H30M |
P1Y2M3D |
// DURATION: temps 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: temps huma
Period p1 = Period.ofDays(21);
Period p2 = Period.of(1, 2, 3); // 1 any, 2 mesos, 3 dies
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 anys, %d mesos, %d dies%n",
p3.getYears(), p3.getMonths(), p3.getDays()); // 0 anys, 6 mesos, 21 diesPer què "un mes" no és un nombre fix de dies
LocalDate gener = LocalDate.of(2026, 1, 1);
LocalDate febrer = LocalDate.of(2026, 2, 1);
System.out.println(ChronoUnit.DAYS.between(gener, febrer)); // 31
LocalDate febrer2 = LocalDate.of(2026, 2, 1);
LocalDate marc = LocalDate.of(2026, 3, 1);
System.out.println(ChronoUnit.DAYS.between(febrer2, marc)); // 28
// I en un any de traspas
System.out.println(ChronoUnit.DAYS.between(
LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1))); // 29Un mes són 28, 29, 30 o 31 dies. Per això Period.ofMonths(1) no es pot convertir a dies sense una data de referència:
Period unMes = Period.ofMonths(1);
System.out.println(unMes.getDays()); // 0, no 30: no hi ha dies en aquest periode
// Per saber quants dies son, cal una data concreta
LocalDate des = LocalDate.of(2026, 1, 31);
LocalDate fins = des.plus(unMes);
System.out.println(ChronoUnit.DAYS.between(des, fins)); // 28La diferència amb l'horari d'estiu
Aquí és on Duration i Period divergeixen de debò:
ZoneId madrid = ZoneId.of("Europe/Madrid");
// La nit del canvi a horari d estiu: 29 de marc de 2026
ZonedDateTime abans = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, madrid);
ZonedDateTime ambPeriod = abans.plus(Period.ofDays(1));
ZonedDateTime ambDuration = abans.plus(Duration.ofDays(1));
System.out.println("Origen: " + abans);
System.out.println("+ Period 1D: " + ambPeriod);
System.out.println("+ Duration 1D: " + ambDuration);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 diferència. Period.ofDays(1) diu "el mateix moment del dia següent" i respecta el calendari: continua essent migdia. Duration.ofDays(1) diu "86.400 segons després", i com que aquella nit només va tenir 23 hores, el resultat és la una de la tarda.
Quin fer servir:
- "Retornar el llibre d'aquí a 21 dies" →
Period. És un termini de calendari; si hi ha canvi d'hora, continua essent el mateix dia a la mateixa hora. - "La sessió expira d'aquí a 30 minuts" →
Duration. És temps real transcorregut. - "Aniversari d'aquí a un any" →
Period. - "El procés va trigar 4,2 segons" →
Duration.
ChronoUnit.between
ChronoUnit.betweenPer a "quantes unitats hi ha entre dues dates", ChronoUnit és l'eina directa:
LocalDate prestec = LocalDate.of(2026, 8, 5);
LocalDate venciment = LocalDate.of(2026, 8, 26);
LocalDate avui = LocalDate.of(2026, 9, 3);
System.out.println(ChronoUnit.DAYS.between(prestec, venciment)); // 21
System.out.println(ChronoUnit.DAYS.between(venciment, avui)); // 8 (retard)
System.out.println(ChronoUnit.WEEKS.between(prestec, avui)); // 4
System.out.println(ChronoUnit.MONTHS.between(prestec, avui)); // 0
System.out.println(ChronoUnit.YEARS.between(prestec, avui)); // 0El truncament sorprèn. MONTHS.between del 5 d'agost al 3 de setembre dona 0, no 1: no ha passat un mes complet. between sempre trunca cap a zero, mai no arrodoneix.
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))); // 1I el signe indica la direcció:
Amb hores i moments:
Instant inici = Instant.now();
Instant fi = inici.plusSeconds(9_045);
System.out.println(ChronoUnit.HOURS.between(inici, fi)); // 2
System.out.println(ChronoUnit.MINUTES.between(inici, fi)); // 150
System.out.println(ChronoUnit.SECONDS.between(inici, fi)); // 9045ChronoUnit.between enfront de Period.between:
LocalDate a = LocalDate.of(2026, 1, 15);
LocalDate b = LocalDate.of(2027, 3, 20);
// ChronoUnit: UNA unitat, valor total
System.out.println(ChronoUnit.DAYS.between(a, b)); // 429
System.out.println(ChronoUnit.MONTHS.between(a, b)); // 14
// Period: DESCOMPOSAT en anys, mesos i dies
Period p = Period.between(a, b);
System.out.printf("%d anys, %d mesos, %d dies%n",
p.getYears(), p.getMonths(), p.getDays()); // 1 anys, 2 mesos, 5 diesFes servir ChronoUnit per calcular ("porta 8 dies de retard") i Period per presentar ("fa 1 any, 2 mesos i 5 dies").
- Ajustadors temporals:
TemporalAdjusters
TemporalAdjustersUn TemporalAdjuster és una operació que transforma una data segons una regla. TemporalAdjusters porta les més útils ja fetes, i resolen càlculs que a mà són incòmodes i propensos a errors.
LocalDate avui = LocalDate.of(2026, 8, 5); // dimecres
System.out.println(avui.with(TemporalAdjusters.firstDayOfMonth())); // 2026-08-01
System.out.println(avui.with(TemporalAdjusters.lastDayOfMonth())); // 2026-08-31
System.out.println(avui.with(TemporalAdjusters.firstDayOfNextMonth())); // 2026-09-01
System.out.println(avui.with(TemporalAdjusters.firstDayOfYear())); // 2026-01-01
System.out.println(avui.with(TemporalAdjusters.lastDayOfYear())); // 2026-12-31
// Dies de la setmana
System.out.println(avui.with(TemporalAdjusters.next(DayOfWeek.MONDAY))); // 2026-08-10
System.out.println(avui.with(TemporalAdjusters.nextOrSame(DayOfWeek.WEDNESDAY))); // 2026-08-05
System.out.println(avui.with(TemporalAdjusters.previous(DayOfWeek.FRIDAY))); // 2026-07-31
// El primer i ultim dia concret del mes
System.out.println(avui.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY))); // 2026-08-03
System.out.println(avui.with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY))); // 2026-08-28
System.out.println(avui.with(TemporalAdjusters.dayOfWeekInMonth(2, DayOfWeek.TUESDAY))); // 2026-08-11La diferència entre next i nextOrSame importa. next(WEDNESDAY) sobre un dimecres retorna el següent dimecres; nextOrSame retorna el mateix dia. Triar malament desplaça tots els venciments una setmana.
Ajustadors propis: dies hàbils a BiblioTech
Quan la regla és de negoci, s'escriu un ajustador propi. TemporalAdjuster és una interfície funcional (04-06), així que pot ser una lambda:
package com.nexussoftware.bibliotech.servei;
import java.time.*;
import java.time.temporal.*;
import java.util.Set;
/**
* Ajustadors de calendari de Nexus Software.
*/
public final class AjustadorsBiblioTech {
private AjustadorsBiblioTech() { }
/** Festius fixos de la biblioteca (MonthDay: es repeteixen cada any). */
private static final Set<MonthDay> FESTIUS = Set.of(
MonthDay.of(1, 1), // Cap d Any
MonthDay.of(1, 6), // Reis
MonthDay.of(5, 1), // Treball
MonthDay.of(8, 15), // Assumpcio
MonthDay.of(10, 12), // Festa Nacional
MonthDay.of(11, 1), // Tots Sants
MonthDay.of(12, 6), // Constitucio
MonthDay.of(12, 8), // Immaculada
MonthDay.of(12, 25)); // Nadal
public static boolean esFestiu(LocalDate data) {
return FESTIUS.contains(MonthDay.from(data));
}
public static boolean esHabil(LocalDate data) {
DayOfWeek dia = data.getDayOfWeek();
return dia != DayOfWeek.SATURDAY
&& dia != DayOfWeek.SUNDAY
&& !esFestiu(data);
}
/**
* Ajustador: mou la data al seguent dia habil si no ho es.
* TemporalAdjuster es funcional: es pot escriure com a lambda.
*/
public static TemporalAdjuster seguentDiaHabil() {
return temporal -> {
LocalDate data = LocalDate.from(temporal);
while (!esHabil(data)) {
data = data.plusDays(1);
}
return temporal.with(data);
};
}
/** Suma N dies HABILS, saltant caps de setmana i festius. */
public static TemporalAdjuster mesDiesHabils(int dies) {
return temporal -> {
LocalDate data = LocalDate.from(temporal);
int restants = dies;
while (restants > 0) {
data = data.plusDays(1);
if (esHabil(data)) {
restants--;
}
}
return temporal.with(data);
};
}
/** Compta dies habils entre dues dates (per a multes justes). */
public static long diesHabilsEntre(LocalDate des, LocalDate fins) {
return des.datesUntil(fins) // Stream<LocalDate> (Java 9)
.filter(AjustadorsBiblioTech::esHabil)
.count();
}
}LocalDate divendres = LocalDate.of(2026, 8, 14);
System.out.println("Divendres 14: " + divendres);
System.out.println("+ 1 dia natural: " + divendres.plusDays(1));
System.out.println("Ajustat a habil: "
+ divendres.plusDays(1).with(AjustadorsBiblioTech.seguentDiaHabil()));
System.out.println("+ 5 dies habils: "
+ divendres.with(AjustadorsBiblioTech.mesDiesHabils(5)));
System.out.println("Dies habils d agost: "
+ AjustadorsBiblioTech.diesHabilsEntre(
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)));Divendres 14: 2026-08-14
+ 1 dia natural: 2026-08-15
Ajustat a habil: 2026-08-17
+ 5 dies habils: 2026-08-24
Dies habils d agost: 20El 15 d'agost és dissabte i festiu, així que l'ajustador salta al dilluns 17. I observa datesUntil, afegit a Java 9: retorna un Stream<LocalDate> amb totes les dates del rang, cosa que connecta directament amb 10-04.
- Zones horàries:
ZoneId i ZoneOffset
ZoneId i ZoneOffsetZoneId identifica una zona amb les seves regles històriques i futures: Europe/Madrid sap quan comença i acaba l'horari d'estiu cada any, i sap que el 1975 les regles eren unes altres.
ZoneOffset és només un desfasament fix respecte a UTC: +02:00. No sap res de regles.
// ZoneId: identificador IANA "Regio/Ciutat"
ZoneId madrid = ZoneId.of("Europe/Madrid");
ZoneId toquio = ZoneId.of("Asia/Tokyo");
ZoneId novaYork = ZoneId.of("America/New_York");
ZoneId utc = ZoneId.of("UTC");
ZoneId perDefecte = ZoneId.systemDefault();
// ZoneOffset: desfasament fix
ZoneOffset mesDos = ZoneOffset.ofHours(2);
ZoneOffset zero = ZoneOffset.UTC;
// Quantes zones hi ha
System.out.println(ZoneId.getAvailableZoneIds().size()); // unes 600La base de dades IANA (també anomenada tz database o Olson database) és el registre mundial de zones horàries, mantingut col·laborativament i actualitzat diverses vegades l'any perquè els governs canvien les regles. Java la inclou i s'actualitza amb les versions del JDK i amb l'eina tzupdater.
Fes servir sempre identificadors Regió/Ciutat. Les abreviatures de tres lletres (CST, IST, EST) estan obsoletes i són ambigües: CST pot ser Central Standard Time (EUA), China Standard Time o Cuba Standard Time.
Conversió entre zones
ZonedDateTime aMadrid = ZonedDateTime.of(2026, 8, 5, 9, 30, 0, 0, ZoneId.of("Europe/Madrid"));
// MATEIX instant, altra zona: l hora local CANVIA
ZonedDateTime aToquio = aMadrid.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));
// MATEIXA hora local, altra zona: l instant CANVIA
ZonedDateTime altreInstant = aMadrid.withZoneSameLocal(ZoneId.of("Asia/Tokyo"));
System.out.println("Madrid: " + aMadrid);
System.out.println("Mateix instant: " + aToquio);
System.out.println("Mateixa hora local: " + altreInstant);
System.out.println("Mateix instant? " + aMadrid.isEqual(aToquio));
System.out.println("Mateix instant? " + aMadrid.isEqual(altreInstant));Madrid: 2026-08-05T09:30+02:00[Europe/Madrid]
Mateix instant: 2026-08-05T16:30+09:00[Asia/Tokyo]
Mateixa hora local: 2026-08-05T09:30+09:00[Asia/Tokyo]
Mateix instant? true
Mateix instant? falseEls dos mètodes fan coses oposades i tots dos són necessaris:
withZoneSameInstant: "quina hora era a Tòquio quan a Madrid eren les 9:30?" És el que fas servir per mostrar un moment a un usuari d'una altra zona.withZoneSameLocal: "la reunió és a les 9:30 hora de Tòquio". Canvia el moment real.
El 99 % de les vegades vols withZoneSameInstant.
El tauler horari de BiblioTech
package com.nexussoftware.bibliotech.presentacio;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
public class RellotgeMundialBiblioTech {
private static final DateTimeFormatter FORMAT =
DateTimeFormatter.ofPattern("EEEE dd/MM/yyyy HH:mm", new Locale("ca", "ES"));
public static void main(String[] args) {
Instant ara = Instant.now();
List<String> seus = List.of(
"Europe/Madrid", "Europe/London", "America/New_York",
"Asia/Tokyo", "Australia/Sydney", "UTC");
System.out.println("Seus de Nexus Software -- instant: " + ara);
System.out.println("-".repeat(62));
seus.stream()
.map(ZoneId::of)
.map(zona -> ara.atZone(zona))
.sorted(java.util.Comparator.comparing(z -> z.getOffset().getTotalSeconds()))
.forEach(z -> System.out.printf(" %-20s %-32s %s%n",
z.getZone(), z.format(FORMAT), z.getOffset()));
}
}Seus de Nexus Software -- instant: 2026-08-05T12:47:31.204Z
--------------------------------------------------------------
America/New_York dimecres 05/08/2026 08:47 -04:00
UTC dimecres 05/08/2026 12:47 Z
Europe/London dimecres 05/08/2026 13:47 +01:00
Europe/Madrid dimecres 05/08/2026 14:47 +02:00
Asia/Tokyo dimecres 05/08/2026 21:47 +09:00
Australia/Sydney dimecres 05/08/2026 22:47 +10:00Un sol Instant, sis rellotges de paret diferents. Això és exactament la regla d'or de l'apartat 5 en acció.
- L'horari d'estiu i els seus dos casos perillosos
El canvi d'hora produeix dues anomalies que trenquen el codi escrit sense pensar-hi. java.time les gestiona, però cal saber què fa.
Cas 1: l'hora que no existeix (salt endavant)
A Espanya, la matinada de l'últim diumenge de març, a les 2:00 els rellotges salten a les 3:00. Les 2:30 d'aquella nit no existeixen.
package com.nexussoftware.bibliotech;
import java.time.*;
public class HoraQueNoExisteix {
public static void main(String[] args) {
ZoneId madrid = ZoneId.of("Europe/Madrid");
// 29 de marc de 2026: canvi a horari d estiu
LocalDateTime inexistent = LocalDateTime.of(2026, 3, 29, 2, 30);
System.out.println("LocalDateTime: " + inexistent);
ZonedDateTime resultat = inexistent.atZone(madrid);
System.out.println("atZone(): " + resultat);
System.out.println("Desfasament: " + resultat.getOffset());
// Comprovar ho explicitament
var regles = madrid.getRules();
System.out.println("Es un forat? " + (regles.getTransition(inexistent) != null));
System.out.println("Durada salt: "
+ regles.getTransition(inexistent).getDuration());
}
}LocalDateTime: 2026-03-29T02:30
atZone(): 2026-03-29T03:30+02:00[Europe/Madrid]
Desfasament: +02:00
Es un forat? true
Durada salt: PT1HatZone no llança excepció: desplaça l'hora cap endavant la mida del salt. Les 2:30 es converteixen en les 3:30.
Conseqüència pràctica per a BiblioTech: un avís programat per a les 2:30 d'aquella matinada s'executarà a les 3:30. Si el sistema programa tasques nocturnes, convé evitar la franja de 2:00 a 3:00 a les zones amb canvi horari.
Cas 2: l'hora que passa dues vegades (salt enrere)
L'últim diumenge d'octubre, a les 3:00 els rellotges retrocedeixen a les 2:00. Les 2:30 passen dues vegades, amb desfasaments diferents.
package com.nexussoftware.bibliotech;
import java.time.*;
public class HoraDuplicada {
public static void main(String[] args) {
ZoneId madrid = ZoneId.of("Europe/Madrid");
// 25 d octubre de 2026: tornada a l horari d hivern
LocalDateTime ambigua = LocalDateTime.of(2026, 10, 25, 2, 30);
System.out.println("LocalDateTime: " + ambigua);
// Per defecte tria la PRIMERA ocurrencia (estiu, +02:00)
ZonedDateTime perDefecte = ambigua.atZone(madrid);
System.out.println("atZone() per defecte: " + perDefecte);
// Eleccio explicita
ZonedDateTime primera = ambigua.atZone(madrid).withEarlierOffsetAtOverlap();
ZonedDateTime segona = ambigua.atZone(madrid).withLaterOffsetAtOverlap();
System.out.println("Primera (estiu): " + primera);
System.out.println("Segona (hivern): " + segona);
System.out.println("Diferencia real: "
+ Duration.between(primera.toInstant(), segona.toInstant()));
var regles = madrid.getRules();
System.out.println("Desfasaments valids: " + regles.getValidOffsets(ambigua));
}
}LocalDateTime: 2026-10-25T02:30
atZone() per defecte: 2026-10-25T02:30+02:00[Europe/Madrid]
Primera (estiu): 2026-10-25T02:30+02:00[Europe/Madrid]
Segona (hivern): 2026-10-25T02:30+01:00[Europe/Madrid]
Diferencia real: PT1H
Desfasaments valids: [+02:00, +01:00]Dos instants reals separats per una hora, amb el mateix rellotge de paret. getValidOffsets retorna dos desfasaments, i això és el senyal inequívoc que l'hora és ambigua.
Conseqüències reals:
- Un préstec registrat a les 2:30 d'aquella matinada podria semblar que va passar abans que un altre registrat a les 2:15... una hora després.
- Ordenar per
LocalDateTimedona un ordre incorrecte; ordenar perInstantdona l'ordre real. - Una tasca programada a les 2:30 es pot executar dues vegades.
La conclusió operativa
| Regla | Motiu |
|---|---|
Guarda Instant, no LocalDateTime |
Els instants no són ambigus ni inexistents |
Ordena i compara per Instant |
L'ordre dels rellotges de paret pot mentir |
Mesura durades amb Instant + Duration |
Restar LocalDateTime inclou el salt horari |
| Evita programar tasques entre les 2:00 i les 3:00 | És la franja de les dues anomalies |
Fes servir Period per a terminis de calendari |
Sobreviu al canvi d'hora sense desplaçar-se |
I la raó final per la qual BiblioTech farà servir LocalDate per a les dates de préstec: les dates de calendari no tenen aquest problema. El 5 d'agost és el 5 d'agost, no hi ha hores que faltin ni que es repeteixin.
- Format i anàlisi amb
DateTimeFormatter
DateTimeFormatterDateTimeFormatter substitueix SimpleDateFormat i és immutable i segur entre fils.
Formats predefinits
LocalDate data = LocalDate.of(2026, 8, 5);
LocalDateTime completa = LocalDateTime.of(2026, 8, 5, 9, 30, 45);
ZonedDateTime ambZona = completa.atZone(ZoneId.of("Europe/Madrid"));
System.out.println(data.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(ambZona.format(DateTimeFormatter.ISO_ZONED_DATE_TIME));
// 2026-08-05T09:30:45+02:00[Europe/Madrid]
System.out.println(ambZona.format(DateTimeFormatter.ISO_INSTANT)); // 2026-08-05T07:30:45Z
System.out.println(data.format(DateTimeFormatter.BASIC_ISO_DATE)); // 20260805ISO-8601 és el format d'intercanvi. 2026-08-05 és inequívoc a tot el món; 05/08/2026 és el 5 d'agost a Catalunya i el 8 de maig als Estats Units. Per persistir i intercanviar dades, sempre ISO. Els formats localitzats són només per mostrar.
I el toString() de totes les classes de java.time ja produeix ISO-8601, cosa que fa que serialitzar sigui trivial.
Patrons propis
DateTimeFormatter catala = DateTimeFormatter.ofPattern("dd/MM/yyyy");
DateTimeFormatter ambHora = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss");
DateTimeFormatter llegible = DateTimeFormatter.ofPattern("EEEE, d 'de' MMMM 'de' yyyy",
new Locale("ca", "ES"));
System.out.println(data.format(catala)); // 05/08/2026
System.out.println(completa.format(ambHora)); // 05/08/2026 09:30:45
System.out.println(data.format(llegible)); // dimecres, 5 de agost de 2026Les lletres de patró més usades:
| Lletra | Significat | Exemple |
|---|---|---|
y |
Any | yyyy → 2026, yy → 26 |
M |
Mes | M → 8, MM → 08, MMM → ag., MMMM → agost |
d |
Dia del mes | d → 5, dd → 05 |
E |
Dia de la setmana | EEE → dc., EEEE → dimecres |
H |
Hora 0-23 | HH → 09 |
h |
Hora 1-12 | hh → 09 |
m |
Minut | mm → 30 |
s |
Segon | ss → 45 |
S |
Fracció de segon | SSS → 123 |
a |
AM/PM | a → a. m. |
z |
Nom de la zona | z → CEST |
Z |
Desfasament | Z → +0200 |
X |
Desfasament ISO | XXX → +02:00 |
'text' |
Literal | 'de' → de |
L'error clàssic: confondre MM amb mm. MM és el mes, mm són els minuts. "dd/mm/yyyy" produeix 05/30/2026, amb els minuts on hauria d'anar el mes.
I el segon: YYYY en lloc de yyyy. Y és l'"any basat en setmanes", que difereix de l'any natural els últims dies de desembre i els primers de gener. Fer servir YYYY produeix l'error famós que el 31 de desembre de 2026 es formata com a 2027. Fes servir yyyy sempre.
Anàlisi i DateTimeParseException
DateTimeFormatter format = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate data = LocalDate.parse("05/08/2026", format);
System.out.println(data); // 2026-08-05
// Sense formatador: s espera ISO-8601
LocalDate iso = LocalDate.parse("2026-08-05");L'anàlisi fallida llança DateTimeParseException, que és no comprovada (estén RuntimeException), així que el compilador no obliga a capturar-la:
package com.nexussoftware.bibliotech.presentacio;
import java.time.LocalDate;
import java.time.format.*;
import java.util.Optional;
public class LectorDeDates {
private static final DateTimeFormatter ENTRADA =
DateTimeFormatter.ofPattern("dd/MM/yyyy");
/** Retorna Optional en lloc de llancar: l entrada dolenta de l usuari es esperable (10-04). */
public static Optional<LocalDate> llegir(String text) {
if (text == null || text.isBlank()) {
return Optional.empty();
}
try {
return Optional.of(LocalDate.parse(text.strip(), ENTRADA));
} catch (DateTimeParseException e) {
System.out.printf(" Data no valida: \"%s\" (posicio %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", "cinc d agost", " 05/08/2026 " }) {
System.out.println("Entrada: \"" + entrada + "\"");
llegir(entrada).ifPresentOrElse(
d -> System.out.println(" -> " + d + " (" + d.getDayOfWeek() + ")"),
() -> System.out.println(" -> descartada"));
}
}
}Entrada: "05/08/2026"
-> 2026-08-05 (WEDNESDAY)
Entrada: "31/02/2026"
Data no valida: "31/02/2026" (posicio 0: Text '31/02/2026' could not be parsed:
Invalid date 'FEBRUARY 31')
-> descartada
Entrada: "2026-08-05"
Data no valida: "2026-08-05" (posicio 0: Text '2026-08-05' could not be parsed at index 2)
-> descartada
Entrada: "cinc d agost"
Data no valida: "cinc d agost" (posicio 0: ...)
-> descartada
Entrada: " 05/08/2026 "
-> 2026-08-05 (WEDNESDAY)DateTimeParseException porta getParsedString() i getErrorIndex(), que permeten missatges d'error precisos — molt millor que el genèric de SimpleDateFormat.
Construir formatadors complexos
Per a casos que un patró no cobreix, existeix DateTimeFormatterBuilder:
DateTimeFormatter tolerant = new DateTimeFormatterBuilder()
.parseCaseInsensitive() // accepta "AG" i "ag"
.appendPattern("dd/MM/yyyy")
.optionalStart() // l hora es opcional
.appendPattern(" HH:mm")
.optionalEnd()
.parseDefaulting(ChronoField.HOUR_OF_DAY, 0) // si falta, mitjanit
.parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
.toFormatter(new Locale("ca", "ES"));
System.out.println(LocalDateTime.parse("05/08/2026", tolerant)); // 2026-08-05T00:00
System.out.println(LocalDateTime.parse("05/08/2026 14:30", tolerant)); // 2026-08-05T14:30Un sol formatador que accepta les dues formes. És exactament el que necessita un importador de CSV que rep dades de diverses fonts.
Locale: noms de mes i dia en català
Locale: noms de mes i dia en catalàSense Locale, els noms surten en l'idioma per defecte de la JVM, que en un servidor sol ser l'anglès:
LocalDate data = LocalDate.of(2026, 8, 5);
DateTimeFormatter senseLocale = DateTimeFormatter.ofPattern("EEEE d 'de' MMMM");
DateTimeFormatter ambCatala = DateTimeFormatter.ofPattern("EEEE d 'de' MMMM",
new Locale("ca", "ES"));
DateTimeFormatter ambCastella = DateTimeFormatter.ofPattern("EEEE d 'de' MMMM",
new Locale("es", "ES"));
System.out.println(data.format(senseLocale)); // Wednesday 5 de August (si la JVM es en angles)
System.out.println(data.format(ambCatala)); // dimecres 5 de agost
System.out.println(data.format(ambCastella)); // miercoles 5 de agostoNo depenguis mai del Locale per defecte en codi de producció. Canvia entre la màquina de desenvolupament i el servidor, i produeix sortides diferents sense cap advertiment.
També hi ha formats localitzats predefinits, que respecten les convencions de cada país:
Locale ca = new Locale("ca", "ES");
System.out.println(data.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(ca)));
// 5/8/26
System.out.println(data.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(ca)));
// 5 d ag. 2026
System.out.println(data.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(ca)));
// 5 d agost de 2026
System.out.println(data.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(ca)));
// dimecres, 5 d agost de 2026I TextStyle per a noms solts:
Locale ca = new Locale("ca", "ES");
System.out.println(DayOfWeek.WEDNESDAY.getDisplayName(TextStyle.FULL, ca)); // dimecres
System.out.println(DayOfWeek.WEDNESDAY.getDisplayName(TextStyle.SHORT, ca)); // dc.
System.out.println(Month.AUGUST.getDisplayName(TextStyle.FULL, ca)); // agost
System.out.println(Month.AUGUST.getDisplayName(TextStyle.SHORT, ca)); // ag.
- Interoperabilitat amb l'API antiga i amb
FileTime
FileTimeEn codi real et trobaràs Date, Calendar i FileTime. Les conversions són directes.
Date i Calendar
// Date -> Instant -> java.time
Date dataAntiga = new Date();
Instant instant = dataAntiga.toInstant();
LocalDateTime local = instant.atZone(ZoneId.systemDefault()).toLocalDateTime();
LocalDate nomesData = instant.atZone(ZoneId.systemDefault()).toLocalDate();
// java.time -> Date
Instant deTornada = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant();
Date dataAntiga2 = Date.from(deTornada);
// 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 pont universal és Instant. Gairebé totes les conversions hi passen, perquè és l'únic tipus que representa el mateix que un Date (un punt a la línia temporal).
Compte amb l'asimetria: Date és un instant; LocalDateTime no ho és. Convertir de LocalDateTime a Date exigeix triar una zona, i l'elecció per defecte (systemDefault()) és una decisió implícita que et pot mossegar.
FileTime: reprenent 07-06
A 07-06 vas llegir els atributs d'un fitxer i les marques de temps sortien com a FileTime, un tipus amb el qual no podies fer res. Ara 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 InventariDeResguards {
private static final DateTimeFormatter FORMAT =
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", new Locale("ca", "ES"));
private static final ZoneId ZONA = ZoneId.of("Europe/Madrid");
public record Resguard(Path cami, LocalDateTime modificat, long bytes) {
public long diesAntiguitat(LocalDate avui) {
return java.time.temporal.ChronoUnit.DAYS.between(modificat.toLocalDate(), avui);
}
}
/** Llista els resguards amb la seva data real, no amb un FileTime opac. */
public List<Resguard> inventariar(Path directori) throws IOException {
try (Stream<Path> camins = Files.list(directori)) {
return camins
.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(".bak"))
.map(this::descriure)
.sorted(java.util.Comparator.comparing(Resguard::modificat).reversed())
.toList();
}
}
private Resguard descriure(Path cami) {
try {
BasicFileAttributes atributs = Files.readAttributes(cami, BasicFileAttributes.class);
// FileTime -> Instant -> LocalDateTime a la zona de la biblioteca
FileTime modificat = atributs.lastModifiedTime();
LocalDateTime data = modificat.toInstant().atZone(ZONA).toLocalDateTime();
return new Resguard(cami, data, atributs.size());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/** Marca un fitxer amb una data concreta: java.time -> FileTime. */
public void datarCom(Path fitxer, LocalDateTime quan) throws IOException {
Instant instant = quan.atZone(ZONA).toInstant();
Files.setLastModifiedTime(fitxer, FileTime.from(instant));
}
/** Esborra els resguards anteriors a la data de tall. */
public int purgarAnteriorsA(Path directori, LocalDate tall) throws IOException {
List<Resguard> caducats = inventariar(directori).stream()
.filter(r -> r.modificat().toLocalDate().isBefore(tall))
.toList();
for (Resguard r : caducats) {
Files.delete(r.cami());
}
return caducats.size();
}
public static void main(String[] args) throws IOException {
InventariDeResguards inventari = new InventariDeResguards();
LocalDate avui = LocalDate.now(ZONA);
System.out.printf("%-28s %-18s %10s %8s%n",
"FITXER", "MODIFICAT", "MIDA", "DIES");
System.out.println("-".repeat(68));
inventari.inventariar(Path.of("resguards")).forEach(r ->
System.out.printf("%-28s %-18s %8d KB %8d%n",
r.cami().getFileName(),
r.modificat().format(FORMAT),
r.bytes() / 1024,
r.diesAntiguitat(avui)));
LocalDate tall = avui.minusMonths(3).with(
java.time.temporal.TemporalAdjusters.firstDayOfMonth());
System.out.println("\nPurgant resguards anteriors a " + tall + "...");
System.out.println("Eliminats: " + inventari.purgarAnteriorsA(Path.of("resguards"), tall));
}
}FITXER MODIFICAT MIDA DIES
--------------------------------------------------------------------
cataleg-20260804.bak 04/08/2026 03:00 1284 KB 1
cataleg-20260728.bak 28/07/2026 03:00 1271 KB 8
cataleg-20260630.bak 30/06/2026 03:00 1198 KB 36
cataleg-20260401.bak 01/04/2026 03:00 1044 KB 126
Purgant resguards anteriors a 2026-05-01...
Eliminats: 1FileTime.toInstant() i FileTime.from(Instant) són el pont en les dues direccions. El deute de 07-06 queda saldat.
Clock: la font de temps injectable
Clock: la font de temps injectableAquest apartat sembla tècnic i és, en realitat, un dels més importants de la lliçó.
El problema: aquest codi és impossible de provar de manera fiable.
public class CalculadoraMultes {
public double calcular(Prestec prestec) {
LocalDate avui = LocalDate.now(); // <-- el problema
long retard = ChronoUnit.DAYS.between(prestec.getDataVenciment(), avui);
return retard <= 0 ? 0.0 : retard * 0.25;
}
}LocalDate.now() llegeix el rellotge del sistema. Per provar "un préstec amb 30 dies de retard" hauries de:
- Canviar l'hora del sistema (inviable en integració contínua).
- Crear el préstec amb data de venciment fa 30 dies, cosa que fa la prova dependent del dia en què s'executi — i fallarà el dia que caigui en un cas límit.
- No provar el cas, que és el que sol passar.
La solució: Clock, una abstracció de la font de temps que s'injecta en lloc de llegir-se globalment.
// Rellotge del sistema a la zona per defecte
Clock sistema = Clock.systemDefaultZone();
// Rellotge del sistema a una zona concreta
Clock madrid = Clock.system(ZoneId.of("Europe/Madrid"));
// Rellotge FIX: sempre retorna el mateix instant
Clock fix = Clock.fixed(Instant.parse("2026-08-05T10:00:00Z"), ZoneId.of("Europe/Madrid"));
// Rellotge desplacat respecte a un altre
Clock dAqui30Dies = Clock.offset(sistema, Duration.ofDays(30));
// Rellotge amb granularitat reduida (util per a proves deterministes)
Clock perSegons = Clock.tickSeconds(ZoneId.of("Europe/Madrid"));Tots els now() accepten un Clock:
LocalDate.now(rellotge);
LocalDateTime.now(rellotge);
ZonedDateTime.now(rellotge);
Instant.now(rellotge);La versió provable de la calculadora:
package com.nexussoftware.bibliotech.servei;
import com.nexussoftware.bibliotech.domini.Prestec;
import java.time.*;
import java.time.temporal.ChronoUnit;
/**
* Calculadora de multes amb rellotge INJECTAT.
* En produccio rep el rellotge del sistema; en proves, un de fix.
*/
public class CalculadoraMultes {
private static final double EURO_PER_DIA = 0.25;
private static final double SOSTRE = 20.0;
private static final int DIES_DE_CORTESIA = 2;
private final Clock rellotge;
/** Constructor de produccio. */
public CalculadoraMultes() {
this(Clock.system(ZoneId.of("Europe/Madrid")));
}
/** Constructor per a proves i per a escenaris amb zona explicita. */
public CalculadoraMultes(Clock rellotge) {
this.rellotge = java.util.Objects.requireNonNull(rellotge, "rellotge");
}
public LocalDate avui() {
return LocalDate.now(rellotge); // mai LocalDate.now() a seques
}
public long diesDeRetard(Prestec prestec) {
long dies = ChronoUnit.DAYS.between(prestec.getDataVenciment(), avui());
return Math.max(0, dies);
}
public double calcular(Prestec prestec) {
long retard = diesDeRetard(prestec);
if (retard <= DIES_DE_CORTESIA) {
return 0.0;
}
return Math.min((retard - DIES_DE_CORTESIA) * EURO_PER_DIA, SOSTRE);
}
public boolean estaVencut(Prestec prestec) {
return avui().isAfter(prestec.getDataVenciment());
}
public boolean venceEnElsProxims(Prestec prestec, int dies) {
LocalDate limit = avui().plusDays(dies);
LocalDate venciment = prestec.getDataVenciment();
return !venciment.isBefore(avui()) && !venciment.isAfter(limit);
}
}I ara la prova és determinista i no depèn del dia d'execució:
package com.nexussoftware.bibliotech;
import com.nexussoftware.bibliotech.domini.Prestec;
import com.nexussoftware.bibliotech.servei.CalculadoraMultes;
import java.time.*;
public class ProvaCalculadoraMultes {
private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
public static void main(String[] args) {
// L "avui" de la prova es FIX: 5 d agost de 2026
Clock avuiFix = Clock.fixed(Instant.parse("2026-08-05T10:00:00Z"), MADRID);
CalculadoraMultes calculadora = new CalculadoraMultes(avuiFix);
System.out.printf("%-24s %-12s %8s %10s%n",
"CAS", "VENCIMENT", "RETARD", "MULTA");
System.out.println("-".repeat(58));
comprovar(calculadora, "Sense vencer", LocalDate.of(2026, 8, 20));
comprovar(calculadora, "Venc avui", LocalDate.of(2026, 8, 5));
comprovar(calculadora, "1 dia (cortesia)", LocalDate.of(2026, 8, 4));
comprovar(calculadora, "2 dies (cortesia)", LocalDate.of(2026, 8, 3));
comprovar(calculadora, "3 dies (primera multa)", LocalDate.of(2026, 8, 2));
comprovar(calculadora, "30 dies", LocalDate.of(2026, 7, 6));
comprovar(calculadora, "1 any (sostre)", LocalDate.of(2025, 8, 5));
// I ara "viatgem" 100 dies al futur sense tocar el rellotge del sistema
System.out.println("\n--- El mateix prestec, 100 dies despres ---");
Clock futur = Clock.offset(avuiFix, Duration.ofDays(100));
CalculadoraMultes enElFutur = new CalculadoraMultes(futur);
System.out.println("Avui per a la calculadora: " + enElFutur.avui());
comprovar(enElFutur, "Sense vencer (abans)", LocalDate.of(2026, 8, 20));
}
private static void comprovar(CalculadoraMultes c, String cas, LocalDate venciment) {
Prestec p = new Prestec("PR-TEST", "978-0000000001", "Marta Ruiz",
venciment.minusDays(21), venciment);
System.out.printf("%-24s %-12s %8d %8.2f €%n",
cas, venciment, c.diesDeRetard(p), c.calcular(p));
}
}CAS VENCIMENT RETARD MULTA
----------------------------------------------------------
Sense vencer 2026-08-20 0 0,00 €
Venc avui 2026-08-05 0 0,00 €
1 dia (cortesia) 2026-08-04 1 0,00 €
2 dies (cortesia) 2026-08-03 2 0,00 €
3 dies (primera multa) 2026-08-02 3 0,25 €
30 dies 2026-07-06 30 7,00 €
1 any (sostre) 2025-08-05 365 20,00 €
--- El mateix prestec, 100 dies despres ---
Avui per a la calculadora: 2026-11-13
Sense vencer (abans) 2026-08-20 85 20,00 €Aquesta prova donarà el mateix resultat d'aquí a cinc anys, perquè no depèn del rellotge del sistema. Els casos límit —venç avui, un dia de retard, exactament els dies de cortesia, el sostre— es comproven de manera explícita i reproduïble.
La regla: en qualsevol classe la lògica de la qual depengui d'"ara", injecta un Clock i no cridis mai LocalDate.now() sense argument. A 11-04 veuràs que és exactament el que fa possible provar codi amb dates, i a 11-02 que Spring pot injectar el Clock com qualsevol altra dependència.
- BiblioTech: la migració completa
Ho apliquem tot. Començant per Prestec:
package com.nexussoftware.bibliotech.domini;
import com.nexussoftware.bibliotech.anotacions.CampCsv;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;
/**
* Prestec amb dates REALS.
*
* ABANS: private final int diaPrestec;
* private final int diaVenciment;
* ARA: LocalDate, perque un prestec es un fet de CALENDARI,
* no un instant: no l afecta l horari d estiu.
*/
public class Prestec implements Identificable {
/** Termini estandard de prestec a Nexus Software. */
public static final Period TERMINI_ESTANDARD = Period.ofDays(21);
/** ISO-8601 per persistir: inequivoc a qualsevol pais i sistema. */
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
@CampCsv(nom = "Referencia", ordre = 1)
private final String referencia;
@CampCsv(nom = "ISBN", ordre = 2)
private final String isbn;
@CampCsv(nom = "Empleat", ordre = 3, sensible = true)
private final String empleat;
@CampCsv(nom = "Data prestec", ordre = 4)
private final LocalDate dataPrestec;
@CampCsv(nom = "Data venciment", ordre = 5)
private final LocalDate dataVenciment;
/** Instant EXACTE del registre: per a auditoria es guarda Instant, no LocalDate. */
private final Instant registratEl;
private LocalDate dataDevolucio; // null mentre continui prestat
private final List<Incidencia> incidencies = new ArrayList<>();
public Prestec(String referencia, String isbn, String empleat,
LocalDate dataPrestec, LocalDate dataVenciment) {
this.referencia = Objects.requireNonNull(referencia, "referencia");
this.isbn = Objects.requireNonNull(isbn, "isbn");
this.empleat = Objects.requireNonNull(empleat, "empleat");
this.dataPrestec = Objects.requireNonNull(dataPrestec, "dataPrestec");
this.dataVenciment = Objects.requireNonNull(dataVenciment, "dataVenciment");
if (dataVenciment.isBefore(dataPrestec)) {
throw new IllegalArgumentException(
"El venciment (" + dataVenciment + ") no pot ser anterior "
+ "al prestec (" + dataPrestec + ")");
}
this.registratEl = Instant.now();
}
/** Fabrica que aplica el termini estandard en dies HABILS. */
public static Prestec ambTerminiEstandard(String referencia, String isbn,
String empleat, LocalDate dataPrestec) {
LocalDate venciment = dataPrestec.plus(TERMINI_ESTANDARD)
.with(com.nexussoftware.bibliotech.servei
.AjustadorsBiblioTech.seguentDiaHabil());
return new Prestec(referencia, isbn, empleat, dataPrestec, venciment);
}
// --- Consultes dependents de l "avui": SEMPRE reben la data ---
/** Dies de retard. 0 si encara no ha vencut. */
public long diesDeRetard(LocalDate avui) {
LocalDate referencia = dataDevolucio != null ? dataDevolucio : avui;
return Math.max(0, ChronoUnit.DAYS.between(dataVenciment, referencia));
}
public boolean teRetard(LocalDate avui) {
return diesDeRetard(avui) > 0;
}
public boolean venceEn(LocalDate avui, int dies) {
LocalDate limit = avui.plusDays(dies);
return !dataVenciment.isBefore(avui) && !dataVenciment.isAfter(limit);
}
/** Descomposicio humana del temps transcorregut. */
public Period antiguitat(LocalDate avui) {
return Period.between(dataPrestec, avui);
}
public void retornar(LocalDate quan) {
if (dataDevolucio != null) {
throw new IllegalStateException(
"El prestec " + referencia + " ja es va retornar el " + dataDevolucio);
}
if (quan.isBefore(dataPrestec)) {
throw new IllegalArgumentException("No es pot retornar abans de prestar");
}
this.dataDevolucio = quan;
}
// --- Serialitzacio: SEMPRE ISO-8601 ---
public String aLiniaCsv() {
return String.join(";",
referencia, isbn, empleat,
dataPrestec.format(ISO),
dataVenciment.format(ISO),
dataDevolucio == null ? "" : dataDevolucio.format(ISO),
registratEl.toString()); // Instant.toString() ja es ISO
}
public static Prestec desDeLiniaCsv(String linia) {
String[] c = linia.split(";", -1);
Prestec p = new Prestec(c[0], c[1], c[2],
LocalDate.parse(c[3], ISO),
LocalDate.parse(c[4], ISO));
if (!c[5].isEmpty()) {
p.retornar(LocalDate.parse(c[5], ISO));
}
return p;
}
// --- Getters ---
@Override public String getId() { return referencia; }
public String getIsbn() { return isbn; }
public String getEmpleat() { return empleat; }
public LocalDate getDataPrestec() { return dataPrestec; }
public LocalDate getDataVenciment() { return dataVenciment; }
public Optional<LocalDate> getDataDevolucio() { return Optional.ofNullable(dataDevolucio); }
public Instant getRegistratEl() { return registratEl; }
public List<Incidencia> getIncidencies() { return List.copyOf(incidencies); }
public record Incidencia(String descripcio, Gravetat gravetat, LocalDate quan) { }
}El servei d'avisos, ara amb TemporalAdjusters:
package com.nexussoftware.bibliotech.servei;
import com.nexussoftware.bibliotech.domini.Prestec;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.*;
import java.util.stream.Collectors;
public class ServeiAvisos {
private static final DateTimeFormatter LLEGIBLE =
DateTimeFormatter.ofPattern("EEEE d 'de' MMMM", new Locale("ca", "ES"));
private final Clock rellotge;
public ServeiAvisos(Clock rellotge) {
this.rellotge = Objects.requireNonNull(rellotge);
}
private LocalDate avui() {
return LocalDate.now(rellotge);
}
/** Prestecs que vencen els proxims N dies, agrupats per empleat. */
public Map<String, List<Prestec>> avisosDeVenciment(List<Prestec> prestecs, int dies) {
LocalDate limit = avui().plusDays(dies);
return prestecs.stream()
.filter(p -> p.getDataDevolucio().isEmpty())
.filter(p -> !p.getDataVenciment().isBefore(avui()))
.filter(p -> !p.getDataVenciment().isAfter(limit))
.collect(Collectors.groupingBy(Prestec::getEmpleat, TreeMap::new,
Collectors.toList()));
}
/** L avis s envia el seguent dia HABIL: ningu no llegeix correus el diumenge. */
public LocalDate quanEnviarAvis(Prestec prestec, int diesDAntelacio) {
return prestec.getDataVenciment()
.minusDays(diesDAntelacio)
.with(AjustadorsBiblioTech.seguentDiaHabil());
}
/** Data de l informe mensual: ultim divendres del mes. */
public LocalDate dataInformeMensual() {
return avui().with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY));
}
/** Data de l inventari trimestral: primer dilluns del proxim trimestre. */
public LocalDate dataInventariTrimestral() {
LocalDate avui = avui();
int mesActual = avui.getMonthValue();
int primerMesDelProximTrimestre = ((mesActual - 1) / 3 + 1) * 3 + 1;
LocalDate base = primerMesDelProximTrimestre > 12
? LocalDate.of(avui.getYear() + 1, 1, 1)
: LocalDate.of(avui.getYear(), primerMesDelProximTrimestre, 1);
return base.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY));
}
public String redactarAvis(String empleat, List<Prestec> prestecs) {
StringBuilder sb = new StringBuilder();
sb.append("Benvolgut/da ").append(empleat).append(":\n\n");
sb.append("Us recordem que teniu ").append(prestecs.size())
.append(prestecs.size() == 1 ? " material pendent" : " materials pendents")
.append(" de devolucio:\n\n");
prestecs.stream()
.sorted(Comparator.comparing(Prestec::getDataVenciment))
.forEach(p -> {
long dies = java.time.temporal.ChronoUnit.DAYS
.between(avui(), p.getDataVenciment());
sb.append(String.format(" - %s venc el %s (%s)%n",
p.getIsbn(),
p.getDataVenciment().format(LLEGIBLE),
dies == 0 ? "avui" : "d aqui a " + dies + (dies == 1 ? " dia" : " dies")));
});
sb.append("\nBiblioTech -- Nexus Software\n");
sb.append("Generat el ").append(avui().format(LLEGIBLE)).append('\n');
return sb.toString();
}
}I la configuració fixa la zona, en lloc de dependre de la del sistema:
package com.nexussoftware.bibliotech.persistencia;
import java.time.*;
import java.util.Properties;
/**
* Configuracio (07-07) amb la zona horaria de la biblioteca EXPLICITA.
* Dependre de ZoneId.systemDefault() significa que el comportament
* canvia en desplegar en un servidor configurat en UTC.
*/
public class ConfiguracioTemporal {
private final ZoneId zona;
private final Clock rellotge;
private final LocalTime oberturaBiblioteca;
private final LocalTime tancamentBiblioteca;
private final Period terminiDePrestec;
public ConfiguracioTemporal(Properties propietats) {
this.zona = ZoneId.of(propietats.getProperty("bibliotech.zona", "Europe/Madrid"));
this.rellotge = Clock.system(zona);
this.oberturaBiblioteca = LocalTime.parse(
propietats.getProperty("bibliotech.obertura", "09:00"));
this.tancamentBiblioteca = LocalTime.parse(
propietats.getProperty("bibliotech.tancament", "20:00"));
this.terminiDePrestec = Period.parse(
propietats.getProperty("bibliotech.termini", "P21D"));
}
public ZoneId zona() { return zona; }
public Clock rellotge() { return rellotge; }
public Period terminiDePrestec() { return terminiDePrestec; }
public boolean estaOberta(LocalDateTime quan) {
DayOfWeek dia = quan.getDayOfWeek();
if (dia == DayOfWeek.SATURDAY || dia == DayOfWeek.SUNDAY) {
return false;
}
LocalTime hora = quan.toLocalTime();
return !hora.isBefore(oberturaBiblioteca) && hora.isBefore(tancamentBiblioteca);
}
public boolean estaObertaAra() {
return estaOberta(LocalDateTime.now(rellotge));
}
}# bibliotech.properties
bibliotech.zona=Europe/Madrid
bibliotech.obertura=09:00
bibliotech.tancament=20:00
bibliotech.termini=P21DFixa't en Period.parse("P21D"): el format ISO-8601 de durades permet configurar el termini des d'un fitxer sense inventar cap format propi. P1M seria un mes, P2W dues setmanes.
Taula de què va canviar a cada classe
| Classe | Abans | Ara |
|---|---|---|
Prestec |
int diaPrestec, int diaVenciment |
LocalDate dataPrestec, LocalDate dataVenciment, Optional<LocalDate> dataDevolucio, Instant registratEl |
Prestec.Incidencia |
String descripcio, Gravetat |
Afegeix LocalDate quan |
CalculadoraMultes |
Resta d'enters | ChronoUnit.DAYS.between + Clock injectat |
ServeiAvisos |
Comparacions d'enters | TemporalAdjusters per a dia hàbil, LocalDate per a rangs |
AjustadorsBiblioTech |
No existia | Dies hàbils, festius amb MonthDay, datesUntil |
EscriptorCsv / LectorCsv |
Números sense significat | ISO-8601 amb DateTimeFormatter.ISO_LOCAL_DATE |
Configuracio |
Sense zona | ZoneId explícit, Clock, LocalTime d'obertura, Period de termini |
RegistreOperacions |
System.currentTimeMillis() |
Instant |
InventariDeResguards |
FileTime opac |
FileTime.toInstant().atZone(...) |
SalaReunions |
Franges com a enters | LocalTime d'inici i fi, Duration |
Reserva |
Dia com a enter | LocalDateTime d'inici + Duration |
I el resultat al CSV:
Referencia;ISBN;Empleat;Data prestec;Data venciment;Data devolucio;Registrat el
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.903ZQualsevol sistema del món entén aquest fitxer. Abans contenia els números 196 i 217, que només significaven alguna cosa dins de BiblioTech.
Errors Comuns i Consells
1. Ignorar el valor retornat. data.plusDays(21); no fa res: els objectes són immutables. Cal assignar el resultat. Els IDE avisen; fes-los cas.
2. Fer servir Date, Calendar o SimpleDateFormat en codi nou. No hi ha cap raó per fer-ho des del 2014. I SimpleDateFormat compartit entre fils corromp dades en silenci.
3. Guardar LocalDateTime quan volies un instant. Funciona fins que el servidor canvia de zona o entra un usuari d'un altre país, i llavors les dades ja guardades són irrecuperables. Guarda Instant.
4. Confondre MM amb mm als patrons. MM és el mes, mm els minuts. "dd/mm/yyyy" posa els minuts on va el mes.
5. Fer servir YYYY en lloc de yyyy. Y és l'any basat en setmanes, i el 31 de desembre de 2026 es formata com a 2027. És un error que apareix cada Cap d'Any.
6. Dependre del Locale o de la zona per defecte. Canvien entre la teva màquina i el servidor. Sigues explícit: new Locale("ca", "ES"), ZoneId.of("Europe/Madrid").
7. Cridar LocalDate.now() dins de la lògica de negoci. Fa la classe impossible de provar de manera determinista. Injecta un Clock.
8. Confondre Duration amb Period. Duration.ofDays(1) són 86.400 segons; Period.ofDays(1) és "el dia següent". La nit del canvi horari difereixen en una hora.
9. Esperar que plusMonths sigui reversible. 31 de gener + 1 mes − 1 mes = 28 de gener. L'ajust a l'últim dia vàlid és correcte però no simètric.
10. Fer servir abreviatures de zona de tres lletres. CST és ambigu entre tres zones diferents. Fes servir sempre Regió/Ciutat.
11. Esperar que ChronoUnit.MONTHS.between arrodoneixi. Trunca cap a zero: del 5 d'agost al 4 de setembre hi ha 0 mesos.
12. Suposar que l'hora local sempre existeix i és única. Dues vegades l'any hi ha una hora que no existeix i una altra que passa dues vegades.
13. Guardar el desfasament en lloc de la zona. +02:00 no sap que a l'hivern serà +01:00. Per a esdeveniments futurs, guarda el ZoneId.
Consell 1: fes servir el tipus més restrictiu que serveixi. Si només hi ha data, LocalDate. Afegir hora i zona "per si de cas" introdueix ambigüitat i errors. YearMonth per a informes mensuals, MonthDay per a festius anuals.
Consell 2: ISO-8601 per persistir, format localitzat per mostrar. I no barregis les dues coses: el CSV porta 2026-08-05, la pantalla de l'usuari porta dimecres, 5 d'agost de 2026.
Consell 3: declara els DateTimeFormatter com a static final. Són immutables, segurs entre fils i la seva construcció té cost (10-07). Un per format, reutilitzat sempre.
Consell 4: als mètodes que depenen d'"avui", passa la data com a paràmetre. diesDeRetard(LocalDate avui) és provable; diesDeRetard() que crida internament now() no ho és. És la mateixa idea que el Clock, aplicada al nivell del mètode.
Consell 5: valida els rangs al constructor. Que el venciment no sigui anterior al préstec, que la devolució no sigui anterior al préstec. Un objecte que neix incoherent propaga l'error per tota l'aplicació (03-04).
Consell 6: aprofita datesUntil. Retorna un Stream<LocalDate> i connecta java.time amb tot el de 10-04: des.datesUntil(fins).filter(...).count().
Exercicis
Exercici 1: calendari de la biblioteca
Escriu CalendariBiblioTech amb un Clock injectat que ofereixi:
boolean esHabil(LocalDate): no és dissabte, diumenge ni festiu (fes servirMonthDayper als festius fixos i calcula la Setmana Santa amb l'algorisme de Butcher per als mòbils).LocalDate seguentHabil(LocalDate)iLocalDate sumarDiesHabils(LocalDate, int).long diesHabilsEntre(LocalDate, LocalDate)fent servirdatesUntil.Map<Month, Long> habilsPerMes(int any)amb streams.List<LocalDate> festiusDe(int any)ordenats, distingint fixos i mòbils.String calendariDelMes(YearMonth)que imprimeixi el mes en format calendari amb els dies hàbils i festius marcats.
Prova amb 2026 i comprova que la Setmana Santa cau on ha de caure.
Exercici 2: reserves de sala amb zones horàries
Nexus Software té seus a Madrid, Nova York i Tòquio. Modela un sistema de reserves de sales que funcioni entre fusos:
record Reserva(String id, String sala, String empleat, ZonedDateTime inici, Duration durada).ZonedDateTime fi()derivat.boolean solapaAmb(Reserva)que funcioni encara que les reserves siguin en zones diferents (compara instants).GestorReservesambResultat<Reserva> reservar(...)(elResultat<T>de 10-01) que rebutgi solapaments, reserves fora de l'horari laboral de la seu de la sala, i reserves al passat.String agendaPer(String empleat, ZoneId zonaDeLEmpleat)que mostri totes les seves reserves a la seva zona.- Demostra amb codi el cas d'una reserva creada a Tòquio que un empleat de Madrid veu el dia anterior.
- Afegeix un cas que caigui la nit del canvi d'horari i explica el resultat.
Exercici 3: informe temporal de préstecs
Amb Prestec ja migrat a LocalDate, escriu InformeTemporal que, combinant java.time amb els streams de 10-04, produeixi:
- Préstecs per
YearMonth, en ordre cronològic. - Durada mitjana real dels préstecs retornats, en dies, fent servir
ChronoUnit. - Distribució per dia de la setmana del préstec (
DayOfWeek), amb histograma. - Els 5 préstecs amb més retard a data del
Clockinjectat. - Préstecs que vencen aquesta setmana (de dilluns a diumenge de la setmana actual), fent servir
TemporalAdjusters. - Un "mapa de calor" per mes i dia de la setmana:
Map<Month, Map<DayOfWeek, Long>>. - Taxa de devolució puntual (retornats abans o el dia del venciment) per trimestre.
Tot amb un Clock fix perquè els resultats siguin reproduïbles.
Solucions
Solució 1
package com.nexussoftware.bibliotech.servei;
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;
/**
* Calendari laboral de la biblioteca de Nexus Software.
* Rellotge injectat: provable de manera determinista.
*/
public class CalendariBiblioTech {
private static final Locale CA = new Locale("ca", "ES");
/** Festius de data FIXA: MonthDay, perque es repeteixen cada any. */
private static final Map<MonthDay, String> FESTIUS_FIXOS = Map.ofEntries(
Map.entry(MonthDay.of(1, 1), "Cap d Any"),
Map.entry(MonthDay.of(1, 6), "Reis"),
Map.entry(MonthDay.of(5, 1), "Dia del Treball"),
Map.entry(MonthDay.of(8, 15), "Assumpcio"),
Map.entry(MonthDay.of(10, 12), "Festa Nacional"),
Map.entry(MonthDay.of(11, 1), "Tots Sants"),
Map.entry(MonthDay.of(12, 6), "Constitucio"),
Map.entry(MonthDay.of(12, 8), "Immaculada"),
Map.entry(MonthDay.of(12, 25), "Nadal"));
private final Clock rellotge;
/** Cache de festius mobils per any: el calcul no canvia mai. */
private final Map<Integer, Map<LocalDate, String>> mobilsPerAny = new HashMap<>();
public CalendariBiblioTech(Clock rellotge) {
this.rellotge = Objects.requireNonNull(rellotge, "rellotge");
}
public LocalDate avui() {
return LocalDate.now(rellotge);
}
// ------------------------------------------------------------------
// 1. Dies habils i festius
// ------------------------------------------------------------------
public boolean esCapDeSetmana(LocalDate data) {
DayOfWeek d = data.getDayOfWeek();
return d == DayOfWeek.SATURDAY || d == DayOfWeek.SUNDAY;
}
public Optional<String> nomDelFestiu(LocalDate data) {
String fix = FESTIUS_FIXOS.get(MonthDay.from(data));
if (fix != null) {
return Optional.of(fix);
}
return Optional.ofNullable(festiusMobils(data.getYear()).get(data));
}
public boolean esFestiu(LocalDate data) {
return nomDelFestiu(data).isPresent();
}
public boolean esHabil(LocalDate data) {
return !esCapDeSetmana(data) && !esFestiu(data);
}
/**
* Algorisme de Butcher (o de Meeus/Jones/Butcher) per al diumenge de Pasqua
* al calendari gregoria. A partir d ell se n deriven els altres.
*/
private Map<LocalDate, String> festiusMobils(int any) {
return mobilsPerAny.computeIfAbsent(any, y -> {
LocalDate pasqua = diumengeDePasqua(y);
Map<LocalDate, String> mobils = new LinkedHashMap<>();
mobils.put(pasqua.minusDays(3), "Dijous Sant");
mobils.put(pasqua.minusDays(2), "Divendres Sant");
mobils.put(pasqua.plusDays(1), "Dilluns de Pasqua");
return mobils;
});
}
public static LocalDate diumengeDePasqua(int any) {
int a = any % 19;
int b = any / 100;
int c = any % 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(any, mes, dia);
}
// ------------------------------------------------------------------
// 2. Navegacio per dies habils
// ------------------------------------------------------------------
public LocalDate seguentHabil(LocalDate des) {
LocalDate data = des;
while (!esHabil(data)) {
data = data.plusDays(1);
}
return data;
}
public LocalDate sumarDiesHabils(LocalDate des, int dies) {
if (dies < 0) {
throw new IllegalArgumentException("Dies negatius: " + dies);
}
LocalDate data = des;
int restants = dies;
while (restants > 0) {
data = data.plusDays(1);
if (esHabil(data)) {
restants--;
}
}
return data;
}
// ------------------------------------------------------------------
// 3-5. Consultes agregades amb streams (10-04)
// ------------------------------------------------------------------
/** datesUntil (Java 9) retorna un Stream<LocalDate>: el pont amb 10-04. */
public long diesHabilsEntre(LocalDate des, LocalDate finsExclusiu) {
return des.datesUntil(finsExclusiu)
.filter(this::esHabil)
.count();
}
public Map<Month, Long> habilsPerMes(int any) {
return LocalDate.of(any, 1, 1)
.datesUntil(LocalDate.of(any + 1, 1, 1))
.filter(this::esHabil)
.collect(Collectors.groupingBy(LocalDate::getMonth,
() -> new EnumMap<>(Month.class),
Collectors.counting()));
}
public List<Map.Entry<LocalDate, String>> festiusDe(int any) {
return LocalDate.of(any, 1, 1)
.datesUntil(LocalDate.of(any + 1, 1, 1))
.filter(this::esFestiu)
.map(f -> Map.entry(f, nomDelFestiu(f).orElse("?")))
.sorted(Map.Entry.comparingByKey())
.toList();
}
// ------------------------------------------------------------------
// 6. Calendari visual
// ------------------------------------------------------------------
public String calendariDelMes(YearMonth mes) {
StringBuilder sb = new StringBuilder();
String titol = mes.getMonth().getDisplayName(TextStyle.FULL, CA).toUpperCase()
+ " " + mes.getYear();
sb.append(String.format("%s%n", centrar(titol, 28)));
sb.append(" Dl Dt Dc Dj Dv Ds Dg\n");
LocalDate primer = mes.atDay(1);
// getValue(): 1 = dilluns ... 7 = diumenge (ISO)
int foratInicial = primer.getDayOfWeek().getValue() - 1;
sb.append(" ".repeat(foratInicial));
for (int dia = 1; dia <= mes.lengthOfMonth(); dia++) {
LocalDate data = mes.atDay(dia);
String marca;
if (esFestiu(data)) marca = "*"; // festiu
else if (esCapDeSetmana(data)) marca = "."; // cap de setmana
else marca = " "; // habil
sb.append(String.format("%3d%s", dia, marca));
if (data.getDayOfWeek() == DayOfWeek.SUNDAY) {
sb.append('\n');
}
}
if (mes.atEndOfMonth().getDayOfWeek() != DayOfWeek.SUNDAY) {
sb.append('\n');
}
long habils = diesHabilsEntre(mes.atDay(1), mes.plusMonths(1).atDay(1));
sb.append(String.format("%nDies habils: %d de %d (* festiu, . cap de setmana)%n",
habils, mes.lengthOfMonth()));
festiusDe(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 text, int amplada) {
int esquerra = Math.max(0, (amplada - text.length()) / 2);
return " ".repeat(esquerra) + text;
}
}Prova:
package com.nexussoftware.bibliotech;
import com.nexussoftware.bibliotech.servei.CalendariBiblioTech;
import java.time.*;
public class ProvaCalendari {
public static void main(String[] args) {
Clock fix = Clock.fixed(Instant.parse("2026-08-05T10:00:00Z"),
ZoneId.of("Europe/Madrid"));
CalendariBiblioTech calendari = new CalendariBiblioTech(fix);
System.out.println("Avui: " + calendari.avui() + " ("
+ calendari.avui().getDayOfWeek() + "), habil: "
+ calendari.esHabil(calendari.avui()));
System.out.println("\nPasqua 2026: " + CalendariBiblioTech.diumengeDePasqua(2026));
System.out.println("Pasqua 2027: " + CalendariBiblioTech.diumengeDePasqua(2027));
System.out.println("\n--- Festius de 2026 ---");
calendari.festiusDe(2026).forEach(e ->
System.out.printf(" %s %-18s (%s)%n",
e.getKey(), e.getValue(), e.getKey().getDayOfWeek()));
System.out.println("\n--- Dies habils per mes el 2026 ---");
calendari.habilsPerMes(2026).forEach((mes, n) ->
System.out.printf(" %-12s %2d %s%n", mes, n, "#".repeat(n.intValue())));
System.out.println("\n" + calendari.calendariDelMes(YearMonth.of(2026, 4)));
System.out.println(calendari.calendariDelMes(YearMonth.of(2026, 8)));
System.out.println("--- Navegacio ---");
LocalDate divendres = LocalDate.of(2026, 8, 14);
System.out.println("Divendres 14/08: " + divendres);
System.out.println("Seguent habil des del 15: "
+ calendari.seguentHabil(divendres.plusDays(1)));
System.out.println("+10 dies habils: "
+ calendari.sumarDiesHabils(divendres, 10));
}
}Avui: 2026-08-05 (WEDNESDAY), habil: true
Pasqua 2026: 2026-04-05
Pasqua 2027: 2027-03-28
--- Festius de 2026 ---
2026-01-01 Cap d Any (THURSDAY)
2026-01-06 Reis (TUESDAY)
2026-04-02 Dijous Sant (THURSDAY)
2026-04-03 Divendres Sant (FRIDAY)
2026-04-06 Dilluns de Pasqua (MONDAY)
2026-05-01 Dia del Treball (FRIDAY)
2026-08-15 Assumpcio (SATURDAY)
2026-10-12 Festa Nacional (MONDAY)
2026-11-01 Tots Sants (SUNDAY)
2026-12-06 Constitucio (SUNDAY)
2026-12-08 Immaculada (TUESDAY)
2026-12-25 Nadal (FRIDAY)
--- Dies habils per mes el 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
Dl Dt Dc Dj Dv Ds Dg
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
Dies habils: 19 de 30 (* festiu, . cap de setmana)
2 Dijous Sant
3 Divendres Sant
6 Dilluns de Pasqua
AGOST 2026
Dl Dt Dc Dj Dv Ds Dg
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
Dies habils: 21 de 31 (* festiu, . cap de setmana)
15 Assumpcio
--- Navegacio ---
Divendres 14/08: 2026-08-14
Seguent habil des del 15: 2026-08-17
+10 dies habils: 2026-08-31Comentaris.
MonthDay és exactament el tipus correcte per a festius fixos. Un LocalDate obligaria a repetir la llista cada any o a construir-la dinàmicament. MonthDay.of(12, 25) diu "el 25 de desembre, qualsevol any", que és el que un festiu fix és.
La memòria cau de festius mòbils importa més del que sembla. habilsPerMes(2026) crida esHabil 365 vegades, i cada crida necessita els festius mòbils de l'any. Sense computeIfAbsent, l'algorisme de Butcher s'executaria 365 vegades per al mateix resultat.
datesUntil connecta les dues lliçons. LocalDate.datesUntil(fi) retorna un Stream<LocalDate>, i a partir d'aquí tot 10-04 s'aplica: filter, groupingBy, counting. Els 12 mesos amb els seus dies hàbils surten en una sola expressió.
El 15 d'agost de 2026 és dissabte i festiu alhora, cosa que il·lustra per què esHabil ha de comprovar les dues coses i per què el recompte d'agost dona 21 i no 20.
Solució 2
package com.nexussoftware.bibliotech.domini;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Objects;
/**
* Reserva de sala amb zona horaria explicita.
* ZonedDateTime perque cal respectar l horari laboral LOCAL
* de cada seu i el canvi d hora.
*/
public record Reserva(String id, String sala, String empleat,
ZonedDateTime inici, Duration durada) {
private static final DateTimeFormatter LLEGIBLE =
DateTimeFormatter.ofPattern("EEE dd/MM HH:mm", new Locale("ca", "ES"));
public Reserva {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(sala, "sala");
Objects.requireNonNull(empleat, "empleat");
Objects.requireNonNull(inici, "inici");
Objects.requireNonNull(durada, "durada");
if (durada.isNegative() || durada.isZero()) {
throw new IllegalArgumentException("La durada ha de ser positiva: " + durada);
}
if (durada.toHours() > 8) {
throw new IllegalArgumentException("Una reserva no pot durar mes de 8 hores");
}
}
public ZonedDateTime fi() {
return inici.plus(durada);
}
/**
* Compara INSTANTS, no hores locals.
* Aixi funciona encara que les dues reserves siguin en zones diferents
* i encara que hi hagi canvi d horari pel mig.
*/
public boolean solapaAmb(Reserva altra) {
if (!sala.equals(altra.sala)) {
return false;
}
Instant elMeuInici = inici.toInstant();
Instant elMeuFi = fi().toInstant();
Instant elSeuInici = altra.inici.toInstant();
Instant elSeuFi = altra.fi().toInstant();
return elMeuInici.isBefore(elSeuFi) && elSeuInici.isBefore(elMeuFi);
}
/** La mateixa reserva vista des d una altra zona: MATEIX instant, altre rellotge. */
public Reserva vistaDes(ZoneId zona) {
return new Reserva(id, sala, empleat, inici.withZoneSameInstant(zona), durada);
}
public String descriure() {
return String.format("%-8s %-12s %-14s %s -- %s (%dh%02dm) [%s]",
id, sala, empleat,
inici.format(LLEGIBLE), fi().format(LLEGIBLE),
durada.toHours(), durada.toMinutesPart(),
inici.getZone());
}
}package com.nexussoftware.bibliotech.servei;
import com.nexussoftware.bibliotech.domini.Reserva;
import java.time.*;
import java.util.*;
import java.util.stream.Collectors;
public class GestorReserves {
/** Seu de cada sala: defineix el seu horari laboral local. */
private static final Map<String, ZoneId> SEU_DE_LA_SALA = Map.of(
"Madrid-A", ZoneId.of("Europe/Madrid"),
"Madrid-B", ZoneId.of("Europe/Madrid"),
"NovaYork-1", ZoneId.of("America/New_York"),
"Toquio-1", ZoneId.of("Asia/Tokyo"));
private static final LocalTime OBERTURA = LocalTime.of(8, 0);
private static final LocalTime TANCAMENT = LocalTime.of(20, 0);
private final List<Reserva> reserves = new ArrayList<>();
private final Clock rellotge;
public GestorReserves(Clock rellotge) {
this.rellotge = Objects.requireNonNull(rellotge);
}
public Resultat<Reserva> reservar(String id, String sala, String empleat,
ZonedDateTime inici, Duration durada) {
ZoneId seu = SEU_DE_LA_SALA.get(sala);
if (seu == null) {
return Resultat.fallada("No existeix la sala '" + sala + "'");
}
// 1. No es reserva al passat: comparacio d INSTANTS
if (inici.toInstant().isBefore(Instant.now(rellotge))) {
return Resultat.fallada("No es pot reservar al passat (" + inici + ")");
}
Reserva candidata;
try {
candidata = new Reserva(id, sala, empleat, inici, durada);
} catch (IllegalArgumentException e) {
return Resultat.fallada(e.getMessage());
}
// 2. Horari laboral A LA ZONA DE LA SALA, no a la de qui reserva
ZonedDateTime iniciLocal = inici.withZoneSameInstant(seu);
ZonedDateTime fiLocal = candidata.fi().withZoneSameInstant(seu);
if (esCapDeSetmana(iniciLocal)) {
return Resultat.fallada(String.format(
"La sala %s esta tancada: %s es %s a %s",
sala, iniciLocal.toLocalDate(), iniciLocal.getDayOfWeek(), seu));
}
if (iniciLocal.toLocalTime().isBefore(OBERTURA)
|| fiLocal.toLocalTime().isAfter(TANCAMENT)
|| !iniciLocal.toLocalDate().equals(fiLocal.toLocalDate())) {
return Resultat.fallada(String.format(
"Fora de l horari de %s (%s-%s hora local): %s a %s",
sala, OBERTURA, TANCAMENT,
iniciLocal.toLocalTime(), fiLocal.toLocalTime()));
}
// 3. Solapaments, comparant instants
Optional<Reserva> conflicte = reserves.stream()
.filter(candidata::solapaAmb)
.findFirst();
if (conflicte.isPresent()) {
return Resultat.fallada("Solapa amb " + conflicte.get().id()
+ " (" + conflicte.get().empleat() + ")");
}
reserves.add(candidata);
return Resultat.exit(candidata);
}
private boolean esCapDeSetmana(ZonedDateTime z) {
DayOfWeek d = z.getDayOfWeek();
return d == DayOfWeek.SATURDAY || d == DayOfWeek.SUNDAY;
}
/** Agenda d un empleat a LA SEVA zona horaria. */
public String agendaPer(String empleat, ZoneId zonaDeLEmpleat) {
List<Reserva> seves = reserves.stream()
.filter(r -> r.empleat().equals(empleat))
.map(r -> r.vistaDes(zonaDeLEmpleat))
.sorted(Comparator.comparing(r -> r.inici().toInstant()))
.toList();
if (seves.isEmpty()) {
return empleat + " no te reserves.";
}
StringBuilder sb = new StringBuilder();
sb.append("Agenda de ").append(empleat)
.append(" (horari de ").append(zonaDeLEmpleat).append(")\n");
seves.stream()
.collect(Collectors.groupingBy(r -> r.inici().toLocalDate(),
TreeMap::new, Collectors.toList()))
.forEach((dia, delDia) -> {
sb.append(" ").append(dia).append(":\n");
delDia.forEach(r -> sb.append(" ").append(r.descriure()).append('\n'));
});
return sb.toString();
}
}La demostració:
package com.nexussoftware.bibliotech;
import com.nexussoftware.bibliotech.servei.*;
import java.time.*;
public class ProvaReservesMultizona {
public static void main(String[] args) {
ZoneId madrid = ZoneId.of("Europe/Madrid");
ZoneId toquio = ZoneId.of("Asia/Tokyo");
ZoneId nyc = ZoneId.of("America/New_York");
Clock rellotge = Clock.fixed(Instant.parse("2026-08-03T06:00:00Z"), madrid);
GestorReserves gestor = new GestorReserves(rellotge);
System.out.println("Ara: " + ZonedDateTime.now(rellotge));
System.out.println();
// 1. Reserva normal a Madrid
mostrar(gestor.reservar("R-001", "Madrid-A", "Marta Ruiz",
ZonedDateTime.of(2026, 8, 5, 10, 0, 0, 0, madrid), Duration.ofHours(2)));
// 2. Solapament a la mateixa sala
mostrar(gestor.reservar("R-002", "Madrid-A", "Diego Alonso",
ZonedDateTime.of(2026, 8, 5, 11, 0, 0, 0, madrid), Duration.ofHours(1)));
// 3. Altra sala a la mateixa hora: SENSE solapament
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 Toquio reservada DES DE MADRID a les 3 de la matinada hora de Madrid
// = 10:00 hora de Toquio: horari laboral CORRECTE alla
mostrar(gestor.reservar("R-004", "Toquio-1", "Nuria Vidal",
ZonedDateTime.of(2026, 8, 6, 3, 0, 0, 0, madrid), Duration.ofHours(1)));
// 5. Sala de Toquio a les 10:00 hora de MADRID = 17:00 a Toquio: encara obert
mostrar(gestor.reservar("R-005", "Toquio-1", "Marta Ruiz",
ZonedDateTime.of(2026, 8, 6, 10, 0, 0, 0, madrid), Duration.ofHours(1)));
// 6. Sala de Toquio a les 16:00 hora de Madrid = 23:00 a Toquio: TANCAT
mostrar(gestor.reservar("R-006", "Toquio-1", "Diego Alonso",
ZonedDateTime.of(2026, 8, 6, 16, 0, 0, 0, madrid), Duration.ofHours(1)));
// 7. Nova York en dissabte hora local
mostrar(gestor.reservar("R-007", "NovaYork-1", "Marta Ruiz",
ZonedDateTime.of(2026, 8, 8, 15, 0, 0, 0, madrid), Duration.ofHours(1)));
// 8. Al passat
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.agendaPer("Marta Ruiz", madrid));
System.out.println(gestor.agendaPer("Marta Ruiz", toquio));
// --- El cas del canvi d horari ---
System.out.println("--- Reserva la nit del canvi d horari ---");
ZonedDateTime nitDelCanvi = ZonedDateTime.of(2026, 10, 25, 1, 30, 0, 0, madrid);
System.out.println("Inici: " + nitDelCanvi);
System.out.println("+2 hores (Duration): " + nitDelCanvi.plus(Duration.ofHours(2)));
System.out.println("Desfasaments valids a les 02:30: "
+ madrid.getRules().getValidOffsets(LocalDateTime.of(2026, 10, 25, 2, 30)));
}
private static void mostrar(Resultat<com.nexussoftware.bibliotech.domini.Reserva> r) {
if (r.esExit()) {
System.out.println("OK " + r.valor().descriure());
} else {
System.out.println("REBUIG " + r.error());
}
}
}Ara: 2026-08-03T08:00+02:00[Europe/Madrid]
OK R-001 Madrid-A Marta Ruiz dc. 05/08 10:00 -- dc. 05/08 12:00 (2h00m) [Europe/Madrid]
REBUIG Solapa amb R-001 (Marta Ruiz)
OK R-003 Madrid-B Diego Alonso dc. 05/08 11:00 -- dc. 05/08 12:00 (1h00m) [Europe/Madrid]
OK R-004 Toquio-1 Nuria Vidal dj. 06/08 03:00 -- dj. 06/08 04:00 (1h00m) [Europe/Madrid]
OK R-005 Toquio-1 Marta Ruiz dj. 06/08 10:00 -- dj. 06/08 11:00 (1h00m) [Europe/Madrid]
REBUIG Fora de l horari de Toquio-1 (08:00-20:00 hora local): 23:00 a 00:00
REBUIG La sala NovaYork-1 esta tancada: 2026-08-08 es SATURDAY a America/New_York
REBUIG No es pot reservar al passat (2026-08-01T10:00+02:00[Europe/Madrid])
Agenda de Marta Ruiz (horari de Europe/Madrid)
2026-08-05:
R-001 Madrid-A Marta Ruiz dc. 05/08 10:00 -- dc. 05/08 12:00 (2h00m) [Europe/Madrid]
2026-08-06:
R-005 Toquio-1 Marta Ruiz dj. 06/08 10:00 -- dj. 06/08 11:00 (1h00m) [Europe/Madrid]
Agenda de Marta Ruiz (horari de Asia/Tokyo)
2026-08-05:
R-001 Madrid-A Marta Ruiz dc. 05/08 17:00 -- dc. 05/08 19:00 (2h00m) [Asia/Tokyo]
2026-08-06:
R-005 Toquio-1 Marta Ruiz dj. 06/08 17:00 -- dj. 06/08 18:00 (1h00m) [Asia/Tokyo]
--- Reserva la nit del canvi d horari ---
Inici: 2026-10-25T01:30+02:00[Europe/Madrid]
+2 hores (Duration): 2026-10-25T02:30+01:00[Europe/Madrid]
Desfasaments valids a les 02:30: [+02:00, +01:00]Comentaris. Cinc punts.
El cas R-004 és el cor de l'exercici. La Núria reserva la sala de Tòquio a les 3 de la matinada hora de Madrid. Sona absurd fins que veus que són les 10 del matí a Tòquio, plenament dins de l'horari laboral d'aquella seu. El sistema accepta perquè valida l'horari a la zona de la sala, no a la de qui reserva.
R-006 és el contrari. En Diego intenta reservar Tòquio a les 16:00 hora de Madrid, que li sembla perfectament raonable, i són les 23:00 a Tòquio. Rebutjat, amb un missatge que explica exactament per què.
R-007 demostra que el dia de la setmana també depèn de la zona. El 8 d'agost és dissabte a les dues zones, però podria no ser-ho prop de la mitjanit. Comparar el DayOfWeek de la zona equivocada produeix rebuigs i acceptacions errònies.
Les dues agendes de la Marta mostren la mateixa reserva amb rellotges diferents. withZoneSameInstant no canvia quan passa la reunió: canvia què marcarà el seu rellotge. Les 10:00 de Madrid són les 17:00 de Tòquio, i totes dues són correctes.
I el cas del canvi d'horari: sumar Duration.ofHours(2) a la 1:30 dona les 2:30 amb desfasament +01:00, no +02:00. Han passat exactament dues hores de temps real, però el rellotge només va avançar una hora aparent perquè a les 3:00 va retrocedir a les 2:00. getValidOffsets retornant dos desfasaments és el senyal que aquella hora local és ambigua.
Solució 3
package com.nexussoftware.bibliotech.servei;
import com.nexussoftware.bibliotech.domini.Prestec;
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 prestecs.
* java.time (10-05) + streams (10-04) + Clock injectat.
*/
public class InformeTemporal {
private static final Locale CA = new Locale("ca", "ES");
private final Clock rellotge;
private final List<Prestec> prestecs;
public InformeTemporal(Clock rellotge, List<Prestec> prestecs) {
this.rellotge = Objects.requireNonNull(rellotge);
this.prestecs = List.copyOf(prestecs);
}
private LocalDate avui() {
return LocalDate.now(rellotge);
}
// --- 1. Prestecs per mes ---
public Map<YearMonth, Long> perMes() {
return prestecs.stream()
.collect(Collectors.groupingBy(
p -> YearMonth.from(p.getDataPrestec()),
TreeMap::new, // ordre cronologic
Collectors.counting()));
}
// --- 2. Durada mitjana real dels retornats ---
public OptionalDouble duradaMitjanaRetornats() {
return prestecs.stream()
.filter(p -> p.getDataDevolucio().isPresent())
.mapToLong(p -> ChronoUnit.DAYS.between(
p.getDataPrestec(), p.getDataDevolucio().orElseThrow()))
.average();
}
// --- 3. Distribucio per dia de la setmana ---
public Map<DayOfWeek, Long> perDiaDeLaSetmana() {
return prestecs.stream()
.collect(Collectors.groupingBy(
p -> p.getDataPrestec().getDayOfWeek(),
() -> new EnumMap<>(DayOfWeek.class),
Collectors.counting()));
}
// --- 4. Els 5 amb mes retard ---
public List<Prestec> mesRetardats(int n) {
LocalDate avui = avui();
return prestecs.stream()
.filter(p -> p.getDataDevolucio().isEmpty())
.filter(p -> p.teRetard(avui))
.sorted(Comparator.comparingLong((Prestec p) -> p.diesDeRetard(avui)).reversed())
.limit(n)
.toList();
}
// --- 5. Vencen aquesta setmana (dilluns a diumenge) ---
public List<Prestec> vencenAquestaSetmana() {
LocalDate dilluns = avui().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
LocalDate diumenge = avui().with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY));
return prestecs.stream()
.filter(p -> p.getDataDevolucio().isEmpty())
.filter(p -> !p.getDataVenciment().isBefore(dilluns))
.filter(p -> !p.getDataVenciment().isAfter(diumenge))
.sorted(Comparator.comparing(Prestec::getDataVenciment))
.toList();
}
// --- 6. Mapa de calor mes x dia de la setmana ---
public Map<Month, Map<DayOfWeek, Long>> mapaDeCalor() {
return prestecs.stream()
.collect(Collectors.groupingBy(
p -> p.getDataPrestec().getMonth(),
() -> new EnumMap<>(Month.class),
Collectors.groupingBy(
p -> p.getDataPrestec().getDayOfWeek(),
() -> new EnumMap<>(DayOfWeek.class),
Collectors.counting())));
}
// --- 7. Puntualitat per trimestre ---
public record Puntualitat(long puntuals, long totals) {
public double percentatge() {
return totals == 0 ? 0.0 : puntuals * 100.0 / totals;
}
}
public Map<String, Puntualitat> puntualitatPerTrimestre() {
return prestecs.stream()
.filter(p -> p.getDataDevolucio().isPresent())
.collect(Collectors.groupingBy(
this::trimestreDe,
TreeMap::new,
Collectors.collectingAndThen(
Collectors.toList(),
llista -> new Puntualitat(
llista.stream().filter(this::fouRetornatATemps).count(),
llista.size()))));
}
private String trimestreDe(Prestec p) {
LocalDate d = p.getDataDevolucio().orElseThrow();
return d.getYear() + "-T" + ((d.getMonthValue() - 1) / 3 + 1);
}
private boolean fouRetornatATemps(Prestec p) {
return !p.getDataDevolucio().orElseThrow().isAfter(p.getDataVenciment());
}
// ------------------------------------------------------------------
public String generar() {
StringBuilder sb = new StringBuilder();
LocalDate avui = avui();
sb.append("=".repeat(64)).append('\n');
sb.append(" INFORME TEMPORAL DE PRESTECS -- ")
.append(avui.format(java.time.format.DateTimeFormatter
.ofPattern("d 'de' MMMM 'de' yyyy", CA))).append('\n');
sb.append("=".repeat(64)).append('\n');
sb.append("\n1. PRESTECS PER MES\n");
perMes().forEach((mes, n) -> sb.append(String.format(" %s %3d %s%n",
mes, n, "▇".repeat(n.intValue()))));
sb.append("\n2. DURADA MITJANA DELS RETORNATS\n");
duradaMitjanaRetornats().ifPresentOrElse(
d -> sb.append(String.format(" %.1f dies%n", d)),
() -> sb.append(" (encara no hi ha devolucions)\n"));
sb.append("\n3. DIA DE LA SETMANA DEL PRESTEC\n");
Map<DayOfWeek, Long> perDia = perDiaDeLaSetmana();
long maxim = perDia.values().stream().mapToLong(Long::longValue).max().orElse(1);
Arrays.stream(DayOfWeek.values()).forEach(d -> {
long n = perDia.getOrDefault(d, 0L);
sb.append(String.format(" %-11s %3d %s%n",
d.getDisplayName(TextStyle.FULL, CA), n,
"▇".repeat((int) (n * 24 / maxim))));
});
sb.append("\n4. ELS 5 AMB MES RETARD\n");
List<Prestec> retardats = mesRetardats(5);
if (retardats.isEmpty()) {
sb.append(" (cap prestec amb retard)\n");
} else {
retardats.forEach(p -> sb.append(String.format(" %-14s %-14s venc %s %3d dies%n",
p.getId(), p.getEmpleat(), p.getDataVenciment(), p.diesDeRetard(avui))));
}
sb.append("\n5. VENCEN AQUESTA SETMANA\n");
List<Prestec> setmana = vencenAquestaSetmana();
if (setmana.isEmpty()) {
sb.append(" (cap)\n");
} else {
setmana.forEach(p -> sb.append(String.format(" %-14s %-14s %s (%s)%n",
p.getId(), p.getEmpleat(), p.getDataVenciment(),
p.getDataVenciment().getDayOfWeek().getDisplayName(TextStyle.FULL, CA))));
}
sb.append("\n6. MAPA DE CALOR (mes x dia)\n");
sb.append(" Dl Dt Dc Dj Dv Ds Dg\n");
mapaDeCalor().forEach((mes, perDiaDelMes) -> {
sb.append(String.format(" %-9s",
mes.getDisplayName(TextStyle.SHORT, CA)));
Arrays.stream(DayOfWeek.values()).forEach(d ->
sb.append(String.format("%4d", perDiaDelMes.getOrDefault(d, 0L))));
sb.append('\n');
});
sb.append("\n7. PUNTUALITAT PER TRIMESTRE\n");
puntualitatPerTrimestre().forEach((trimestre, p) ->
sb.append(String.format(" %-9s %3d/%-3d %5.1f %% %s%n",
trimestre, p.puntuals(), p.totals(), p.percentatge(),
"▇".repeat((int) (p.percentatge() / 5)))));
return sb.toString();
}
}================================================================
INFORME TEMPORAL DE PRESTECS -- 5 de agost de 2026
================================================================
1. PRESTECS PER MES
2026-04 7 ▇▇▇▇▇▇▇
2026-05 11 ▇▇▇▇▇▇▇▇▇▇▇
2026-06 9 ▇▇▇▇▇▇▇▇▇
2026-07 14 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇
2026-08 4 ▇▇▇▇
2. DURADA MITJANA DELS RETORNATS
18,4 dies
3. DIA DE LA SETMANA DEL PRESTEC
dilluns 12 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
dimarts 8 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
dimecres 9 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
dijous 7 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇
divendres 9 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
dissabte 0
diumenge 0
4. ELS 5 AMB MES RETARD
PR-2026-0012 Diego Alonso venc 2026-05-22 75 dies
PR-2026-0019 Marta Ruiz venc 2026-06-15 51 dies
PR-2026-0027 Nuria Vidal venc 2026-07-10 26 dies
PR-2026-0031 Diego Alonso venc 2026-07-24 12 dies
PR-2026-0035 Marta Ruiz venc 2026-08-03 2 dies
5. VENCEN AQUESTA SETMANA
PR-2026-0035 Marta Ruiz 2026-08-03 (dilluns)
PR-2026-0038 Nuria Vidal 2026-08-06 (dijous)
PR-2026-0040 Diego Alonso 2026-08-07 (divendres)
6. MAPA DE CALOR (mes x dia)
Dl Dt Dc Dj Dv Ds Dg
abr. 2 1 2 1 1 0 0
maig 3 2 2 2 2 0 0
juny 2 2 2 1 2 0 0
jul. 4 2 2 2 4 0 0
ag. 1 1 1 1 0 0 0
7. PUNTUALITAT PER TRIMESTRE
2026-T2 14/18 77,8 % ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
2026-T3 9/13 69,2 % ▇▇▇▇▇▇▇▇▇▇▇▇▇
Comentaris.
YearMonth com a clau del TreeMap dona l'ordre cronològic de franc. YearMonth implementa Comparable, així que 2026-04 va abans que 2026-05 sense cap comparador. Amb cadenes "abril 2026" caldria ordenar a mà i sortiria alfabètic.
EnumMap per a DayOfWeek i Month (05-05) no és un detall: és l'estructura correcta per a claus d'enum, més ràpida i amb les claus ja en ordre natural. Un HashMap<DayOfWeek, Long> donaria els dies en ordre arbitrari.
Ni un sol préstec en dissabte o diumenge, cosa que confirma que les dades de prova respecten el calendari laboral i valida de passada el CalendariBiblioTech de l'exercici 1.
El Clock fix fa l'informe reproduïble. "Vencen aquesta setmana" depèn de quin dia és avui, i amb LocalDate.now() l'informe donaria resultats diferents cada dia — impossible de verificar en una prova automàtica. Amb Clock.fixed, l'informe del 5 d'agost de 2026 és sempre el mateix. Això és el que farà possible provar BiblioTech a 11-04.
I previousOrSame(MONDAY) amb nextOrSame(SUNDAY) delimiten la setmana ISO exactament, sense aritmètica amb getDayOfWeek().getValue() propensa a errors de contorn. Quan avui és dilluns, previousOrSame retorna avui, que és el correcte.
Conclusió
Les dates de BiblioTech han deixat de ser enters.
Saps per què va existir el problema: Date i Calendar van ser un desastre de disseny amb mesos des de zero, anys des del 1900, objectes mutables que es modifiquen en passar-los a un mètode, absència total de separació entre "una data" i "un instant", i —l'error més car— SimpleDateFormat compartit entre fils, que no llança excepcions sinó que produeix dades silenciosament incorrectes sota càrrega, en producció, de manera intermitent, després de funcionar perfectament en desenvolupament.
I saps per què java.time és diferent: immutable (tot mètode retorna una còpia), segura entre fils per conseqüència directa de l'anterior, fluida (els mètodes s'encadenen) i explícita sobre la zona horària — amb el nom de la classe declarant exactament què portes: Local... sense zona, Zoned.../Offset... amb ella, Instant com a punt a la línia temporal.
Coneixes les classes nucli i el criteri per triar: LocalDate per a dates de calendari (la majoria de camps de negoci), LocalTime per a hores del dia, LocalDateTime per a totes dues sense zona, ZonedDateTime per a moments que cal mostrar o programar respectant l'horari d'estiu, OffsetDateTime per a intercanvi, Instant per registrar i mesurar, i les petites que s'obliden i són perfectes per al seu cas: YearMonth per a informes mensuals, MonthDay per a festius anuals, Year, Month i DayOfWeek com a enum amb noms localitzables.
Tens claríssima la regla d'or: guarda Instant, mostra ZonedDateTime. Un LocalDateTime no identifica cap moment —les 9:30 del 5 d'agost passen a hores diferents a Madrid i a Tòquio— i guardar-lo a base de dades funciona fins al dia en què el servidor canvia de zona o entra un usuari d'un altre país, i llavors les dades ja guardades són irrecuperables perquè ningú no sap en quina zona es van escriure.
Crees amb now, of (que valida: LocalDate.of(2026, 2, 30) llança DateTimeException en lloc de convertir-se silenciosament en el 2 de març) i parse. Consultes amb getYear, getDayOfWeek, lengthOfMonth, isLeapYear. Manipules amb plusX, minusX i withX, recordant assignar el resultat perquè els objectes són immutables i data.plusDays(21); no fa absolutament res. I saps que l'aritmètica de mesos ajusta a l'últim dia vàlid i per tant no és reversible: 31 de gener més un mes menys un mes és el 28 de gener.
Distingeixes Duration de Period: temps de màquina enfront de temps humà, segons enfront d'anys-mesos-dies — i has vist la diferència real la nit del canvi horari, on Period.ofDays(1) manté l'hora i Duration.ofDays(1) la desplaça seixanta minuts. Calcules amb ChronoUnit.between, sabent que trunca cap a zero i que del 5 d'agost al 4 de setembre hi ha zero mesos. I entens per què "un mes" no és un nombre fix de dies: són 28, 29, 30 o 31, i Period.ofMonths(1) no es pot convertir a dies sense una data de referència.
Fas servir TemporalAdjusters per al que a mà és incòmode i propens a errors —firstDayOfMonth, lastInMonth(FRIDAY), next enfront de nextOrSame— i escrius els teus, perquè TemporalAdjuster és una interfície funcional i una lambda n'hi ha prou: el següent dia hàbil de BiblioTech saltant caps de setmana i festius.
Manegues zones horàries amb ZoneId (que coneix les regles històriques i futures de la base de dades IANA) enfront de ZoneOffset (que és només un desfasament fix), amb identificadors Regió/Ciutat i mai abreviatures ambigües de tres lletres. Saps que withZoneSameInstant i withZoneSameLocal fan coses oposades i quina vols el 99 % de les vegades. I coneixes els dos casos perillosos de l'horari d'estiu, demostrats: l'hora que no existeix —on atZone desplaça silenciosament les 2:30 a les 3:30— i l'hora que passa dues vegades —on getValidOffsets retorna dos desfasaments, dos instants reals separats per una hora comparteixen el mateix rellotge de paret, i una tasca programada es pot executar dues vegades o un registre posterior semblar anterior—.
Formates i analitzes amb DateTimeFormatter, que és immutable i segur entre fils i per tant es declara static final sense cap risc. Coneixes les lletres de patró i els dos paranys clàssics: MM és el mes i mm els minuts, i YYYY és l'any basat en setmanes que converteix el 31 de desembre de 2026 en 2027. Fas servir ISO-8601 per persistir i intercanviar i formats localitzats amb Locale explícit per mostrar, sense dependre mai del Locale per defecte de la JVM. I captures DateTimeParseException —no comprovada— retornant Optional per a l'entrada de l'usuari.
Converteixes des de i cap a l'API antiga (Date.from/toInstant, GregorianCalendar.toZonedDateTime, java.sql.Date.toLocalDate) amb Instant com a pont universal, i has saldat el deute de 07-06: FileTime.toInstant() i FileTime.from(Instant) converteixen les marques de temps dels fitxers en dates de veritat.
I sobretot: fas servir Clock. Saps que LocalDate.now() dins de la lògica de negoci fa la classe impossible de provar de manera determinista, i que injectar un Clock —Clock.fixed per congelar el temps, Clock.offset per viatjar al futur— converteix una prova que depèn del dia en què s'executa en una que donarà el mateix resultat d'aquí a cinc anys. Els casos límit de CalculadoraMultes —venç avui, un dia de retard, exactament els dies de cortesia, el sostre de 20 euros— es comproven ara de manera explícita i reproduïble.
BiblioTech ha migrat per complet. Prestec té LocalDate dataPrestec, LocalDate dataVenciment, Optional<LocalDate> dataDevolucio i un Instant registratEl per a l'auditoria, amb validació de rangs al constructor. CalculadoraMultes fa servir ChronoUnit.DAYS.between i rep el seu Clock. ServeiAvisos calcula amb TemporalAdjusters perquè els avisos caiguin en dia hàbil i les dates d'informe siguin l'últim divendres del mes. AjustadorsBiblioTech coneix els festius amb MonthDay i compta dies hàbils amb datesUntil. El CSV porta ISO-8601 que qualsevol sistema del món entén, en lloc dels números 196 i 217 que només significaven alguna cosa dins de BiblioTech. I Configuracio fixa la zona horària explícitament, en lloc d'heretar la del servidor i comportar-se diferent en desplegar.
I ara mira el codi que has escrit en aquesta lliçó. És ple de switch amb ->, de record, de var, de List.of i Map.ofEntries, d'instanceof amb patró, de String.repeat, de Files.readString. Tot això ho has estat fent servir des del mòdul 1 sense que ningú no et digués d'on va sortir, en quina versió va aparèixer ni quin problema resolia. I n'hi ha més que encara no has vist: blocs de text que farien llegible el JSON del mòdul 11, classes segellades que permetrien al compilador verificar que has cobert tots els tipus de Material, pattern matching a switch que substituiria cadenes senceres d'if (x instanceof ...), i els fils virtuals que —com vas anticipar a 09-03— canvien per complet el càlcul d'"un fil per connexió" del ServidorCataleg.
A 10-06, Java 9 i més enllà, s'ordena tot això. Veuràs el calendari semestral de versions i què signifiquen les LTS, el sistema de mòduls que explica per què la reflexió de 10-03 va deixar de poder obrir-ho tot, les incorporacions d'API i de sintaxi versió per versió amb el criteri de què fer servir avui, les classes segellades combinades amb record per modelar tipus algebraics que fan exhaustiu un switch sense default, el pattern matching de Java 21 amb patrons de registre i guardes when, i els fils virtuals, amb la refactorització del ServidorCataleg que fa innecessari el seu pool acotat — juntament amb el "pinning" de synchronized i què és el que no resolen.
Curs de Programació en Java
Mòdul 1: Introducció a Java
- Introducció a Java
- Configuració de l'entorn de desenvolupament
- Sintaxi i estructura bàsica
- Variables i tipus de dades
- Operadors
- Entrada i sortida per consola
- El teu primer programa complet: BiblioTech
Mòdul 2: Flux de control
- Sentències condicionals
- Bucles
- Sentències switch
- Break i continue
- Depuració i traces d'execució
- Projecte: menú interactiu de BiblioTech
Mòdul 3: Programació orientada a objectes
- Introducció a la POO
- Classes i objectes
- Mètodes
- Constructors
- Herència
- Polimorfisme
- Encapsulament
- Abstracció
- La classe Object: equals, hashCode i toString
Mòdul 4: Programació orientada a objectes avançada
- Interfícies
- Classes abstractes
- Classes internes
- Classes anònimes
- Expressions lambda
- Interfícies funcionals i referències a mètodes
- Enumeracions i registres
Mòdul 5: Estructures de dades i col·leccions
- Arrays
- El framework de col·leccions
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cua i Deque
- Pila
- Ordenació i cerca en col·leccions
Mòdul 6: Gestió d'excepcions
- Introducció a les excepcions
- Bloc try-catch
- Throw i throws
- Excepcions personalitzades
- Bloc finally
- Try-with-resources i AutoCloseable
- Estratègies de gestió d'errors i logging
Mòdul 7: Entrada/sortida de fitxers
- Lectura de fitxers
- Escriptura de fitxers
- Fluxos de fitxers
- BufferedReader i BufferedWriter
- Serialització
- L'API NIO.2: Path i Files
- Formats d'intercanvi: CSV i Properties
Mòdul 8: Multifil i concurrència
- Introducció al multifil
- Creació de fils
- Cicle de vida d'un fil
- Sincronització
- Utilitats de concurrència
- Col·leccions concurrents i variables atòmiques
- Tasques asíncrones amb CompletableFuture
Mòdul 9: Xarxes
- Introducció a les xarxes
- Sockets
- ServerSocket
- DatagramSocket i DatagramPacket
- URL i HttpURLConnection
- El client HTTP modern
Mòdul 10: Temes avançats
- Genèrics
- Anotacions
- Reflexió
- Característiques de Java 8: Streams i Optional
- Dates i hores amb java.time
- Java 9 i més enllà
- Memòria, recol·lecció de brossa i rendiment
Mòdul 11: Frameworks i llibreries de Java
- Introducció als frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Proves avançades amb Mockito
- Llibreries essencials de l'ecosistema
