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

  1. Per què va existir el problema: Date i Calendar
  2. L'error clàssic: SimpleDateFormat compartit
  3. Els principis de disseny de java.time
  4. Les classes nucli i com triar
  5. Instant enfront de LocalDateTime: la regla d'or
  6. Creació: now, of, parse
  7. Consulta: obtenir parts i propietats
  8. Manipulació: mètodes que retornen còpies
  9. Comparació
  10. Duration enfront de Period
  11. ChronoUnit.between
  12. Ajustadors temporals: TemporalAdjusters
  13. Zones horàries: ZoneId i ZoneOffset
  14. L'horari d'estiu i els seus dos casos perillosos
  15. Format i anàlisi amb DateTimeFormatter
  16. Locale: noms de mes i dia en català
  17. Interoperabilitat amb l'API antiga i amb FileTime
  18. Clock: la font de temps injectable
  19. BiblioTech: la migració completa
  20. Errors Comuns i Consells
  21. Exercicis

  1. Per què va existir el problema: Date i Calendar

Per 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)

  1. L'error clàssic: SimpleDateFormat compartit

Aquest 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:07

Per 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.

  1. Els principis de disseny de java.time

Quatre 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-04

D'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.

  1. 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:

  1. Només hi ha data, sense hora?LocalDate. És el cas de la majoria de camps de negoci: venciments, dates d'alta, terminis.
  2. És un moment que cal registrar o comparar entre sistemes?Instant.
  3. 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());                  // 365

YearMonth é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.

  1. Instant enfront de LocalDateTime: la regla d'or

Aquesta 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()));  // PT7H

La regla d'or

Guarda Instant (o OffsetDateTime en UTC). Mostra ZonedDateTime. 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.

  1. Creació: now, of, parse

Les 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:

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

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:

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

  1. 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());             // 123000000

Month 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")));  // agost

I 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

  1. 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:59

L'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-05

Els 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 si

Els 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-28

java.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

  1. 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 diferents

Regla: 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.

  1. Duration enfront de Period

Dues 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 dies

Per 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)));     // 29

Un 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));           // 28

La 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.

  1. ChronoUnit.between

Per 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));          // 0

El 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)));     // 1

I el signe indica la direcció:

System.out.println(ChronoUnit.DAYS.between(avui, prestec));       // -29 (negatiu)

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));        // 9045

ChronoUnit.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 dies

Fes servir ChronoUnit per calcular ("porta 8 dies de retard") i Period per presentar ("fa 1 any, 2 mesos i 5 dies").

  1. Ajustadors temporals: TemporalAdjusters

Un 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-11

La 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:   20

El 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.

  1. Zones horàries: ZoneId i ZoneOffset

ZoneId 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 600

La 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?      false

Els 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:00

Un sol Instant, sis rellotges de paret diferents. Això és exactament la regla d'or de l'apartat 5 en acció.

  1. 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:    PT1H

atZone 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 LocalDateTime dona un ordre incorrecte; ordenar per Instant dona 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.

  1. Format i anàlisi amb DateTimeFormatter

DateTimeFormatter 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));      // 20260805

ISO-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 2026

Les 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:30

Un sol formatador que accepta les dues formes. És exactament el que necessita un importador de CSV que rep dades de diverses fonts.

  1. 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 agosto

No 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 2026

I 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.

  1. Interoperabilitat amb l'API antiga i amb FileTime

En 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: 1

FileTime.toInstant() i FileTime.from(Instant) són el pont en les dues direccions. El deute de 07-06 queda saldat.

  1. Clock: la font de temps injectable

Aquest 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.

  1. 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=P21D

Fixa'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.903Z

Qualsevol 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:

  1. boolean esHabil(LocalDate): no és dissabte, diumenge ni festiu (fes servir MonthDay per als festius fixos i calcula la Setmana Santa amb l'algorisme de Butcher per als mòbils).
  2. LocalDate seguentHabil(LocalDate) i LocalDate sumarDiesHabils(LocalDate, int).
  3. long diesHabilsEntre(LocalDate, LocalDate) fent servir datesUntil.
  4. Map<Month, Long> habilsPerMes(int any) amb streams.
  5. List<LocalDate> festiusDe(int any) ordenats, distingint fixos i mòbils.
  6. 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:

  1. record Reserva(String id, String sala, String empleat, ZonedDateTime inici, Duration durada).
  2. ZonedDateTime fi() derivat.
  3. boolean solapaAmb(Reserva) que funcioni encara que les reserves siguin en zones diferents (compara instants).
  4. GestorReserves amb Resultat<Reserva> reservar(...) (el Resultat<T> de 10-01) que rebutgi solapaments, reserves fora de l'horari laboral de la seu de la sala, i reserves al passat.
  5. String agendaPer(String empleat, ZoneId zonaDeLEmpleat) que mostri totes les seves reserves a la seva zona.
  6. Demostra amb codi el cas d'una reserva creada a Tòquio que un empleat de Madrid veu el dia anterior.
  7. 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:

  1. Préstecs per YearMonth, en ordre cronològic.
  2. Durada mitjana real dels préstecs retornats, en dies, fent servir ChronoUnit.
  3. Distribució per dia de la setmana del préstec (DayOfWeek), amb histograma.
  4. Els 5 préstecs amb més retard a data del Clock injectat.
  5. Préstecs que vencen aquesta setmana (de dilluns a diumenge de la setmana actual), fent servir TemporalAdjusters.
  6. Un "mapa de calor" per mes i dia de la setmana: Map<Month, Map<DayOfWeek, Long>>.
  7. 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-31

Comentaris.

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 ClockClock.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. PrestecLocalDate 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

Mòdul 2: Flux de control

Mòdul 3: Programació orientada a objectes

Mòdul 4: Programació orientada a objectes avançada

Mòdul 5: Estructures de dades i col·leccions

Mòdul 6: Gestió d'excepcions

Mòdul 7: Entrada/sortida de fitxers

Mòdul 8: Multifil i concurrència

Mòdul 9: Xarxes

Mòdul 10: Temes avançats

Mòdul 11: Frameworks i llibreries de Java

Mòdul 12: Construcció d'aplicacions del món real

© Copyright 2026. Tots els drets reservats