diff --git a/CLAUDE.md b/CLAUDE.md
index b12cda6..996c4fd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3270,6 +3270,63 @@ corrigeant « Christophe » en « Christophé », livre enregistré et relu par
rôles (`Scénario`, `Dessin`) et ses deux thèmes, fiche rouverte en édition avec ses listes
garnies, aucun débordement horizontal.
+## Le catalogue rassemble les tomes d'une série (2026-08-22)
+
+Demandé en usage : « grouper les livres/BD d'une même série ensemble avec le nom de la série ;
+si on clique dessus, ça envoie à la série ».
+
+### Deux décisions prises avec l'utilisateur
+
+| Question | Décision | Pourquoi |
+|---|---|---|
+| Où se place le bloc ? | **À la place de son premier tome** dans l'ordre du catalogue | L'ordre général ne bouge pas : on retrouve « La Légende de Drizzt » entre « Dracula » et « Dune ». Grouper en tête ou en fin aurait défait l'alphabet là où l'on cherche un livre à sa lettre |
+| Peut-on s'en passer ? | **Bascule « Grouper les livres d'une même série »**, active par défaut | Décochée, on retrouve exactement le catalogue d'avant. Elle **défait le regroupement**, elle ne masque aucun livre — c'est la lecture retenue sur les deux possibles |
+
+⚠️ **La bascule n'entre pas dans le compteur du bouton « Filtrer ».** Ce compteur existe parce
+qu'un filtre replié rendrait des livres invisibles sans qu'on sache pourquoi ; grouper ne cache
+rien, et se voit dans la liste elle-même. Compter un réglage inoffensif diluerait l'avertissement
+que le compteur porte.
+
+### Ce que le regroupement ne fait pas
+
+- **Il ne masque ni ne déplace hors de la liste.** Un livre écarté par un filtre reste écarté, un
+ livre visible reste visible, et le compteur en tête continue de dire des **livres** (« 6
+ livres »), pas des blocs. Un bloc ne montre donc que les tomes que la liste contient — sur une
+ recherche, « 1 tome ».
+- **Il ne va pas chercher les tomes manquants** : le catalogue ne connaît que ce qu'on possède.
+ C'est précisément pourquoi le nom du bloc **mène à la série**, seul écran qui montre les trous.
+- ⚠️ **Il ne duplique jamais un livre.** Le modèle autorise un livre dans plusieurs séries (pas
+ d'unicité sur `LivreId` seul) ; deux cartes du même exemplaire feraient mentir le compteur. La
+ série retenue est la **première par ordre alphabétique** — critère explicable et surtout
+ **stable**, là où « celle que l'API a rendue en premier » changerait le nom du bloc d'un
+ chargement à l'autre. La fiche livre, elle, montre toutes ses séries.
+- **Un seul tome possédé forme quand même un bloc** : c'est ce qui dit à quoi ce livre appartient.
+
+### Les séries ne sont lues qu'une fois par visite
+
+⚠️ Le catalogue se recharge **à chaque frappe** dans la recherche. Y joindre une lecture des
+séries ferait une requête par caractère pour une liste qui ne bouge pas pendant qu'on tape. Elles
+sont donc lues à l'arrivée sur l'écran et à chaque bascule du réseau. Prix assumé : un tome
+rattaché depuis un autre écran n'apparaît groupé qu'au prochain passage au catalogue.
+
+**Une lecture ratée ne coûte rien** : sans série, la liste s'affiche à plat — l'écran d'avant.
+Hors-ligne, elle retombe d'elle-même sur l'instantané `series`, déjà tenu à jour.
+
+### Le regroupement est une fonction pure, donc éprouvable
+
+`Services/GroupementCatalogue.cs` ne dépend d'aucun composant : six tests portent sur ce qui a
+été **décidé** — la place du bloc, l'ordre de lecture à l'intérieur, le livre à deux séries qui
+n'apparaît qu'une fois et toujours sous la même, la liste plate sans séries, le bloc à un seul
+tome, et le filtre qui ne se laisse pas défaire. C'est le même parti que `FiltreLivresLocal` :
+ce qui tient une règle sort du balisage.
+
+**Vérifié en exécution** (320 px puis 1280 px, base de développement garnie de trois tomes et
+d'un livre hors série) : blocs « L'Elfe noir » (2 tomes, dans l'ordre de **lecture** — l'inverse
+de l'alphabet) et « La Trilogie des Elfes noirs » (1 tome) posés à la place de leur premier tome,
+compteur toujours « 6 livres », bascule qui défait et refait le groupement sans qu'aucune carte
+n'apparaisse ni ne disparaisse (6 dans les deux cas), recherche « terre natale » → « 1 livre » et
+un bloc à un tome, clic sur le nom → `/series/8`. Aucun débordement horizontal. 623 tests au vert.
+
## La 7ᵉ série vérifiée en navigateur — trois défauts, tous invisibles aux tests (2026-08-21)
La série Q à Y s'était close sur un aveu : « **aucun** écran n'a été regardé s'afficher ». Le
diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor
index a0157d2..1e196ea 100644
--- a/MaBibli.Client/Pages/Catalogue.razor
+++ b/MaBibli.Client/Pages/Catalogue.razor
@@ -110,6 +110,22 @@
}
+ @*
+ ⚠️ Grouper ne CACHE rien — c'est pourquoi cette bascule n'entre pas dans le compteur
+ du bouton « Filtrer », qui ne compte que ce qui rendrait des livres invisibles. Elle
+ se voit d'ailleurs dans la liste elle-même : avec ou sans blocs de série.
+
+ Décochée, on retrouve exactement le catalogue d'avant : tous les livres à plat.
+ *@
+ @if (_series is { Count: > 0 })
+ {
+
+ }
+
Le statut de lecture est le vôtre : filtrer dessus montre votre
lecture, pas celle du foyer. Le prêt, lui, est commun.
@@ -136,12 +152,41 @@ else if (_livres is not null)
{
+ @*
+ Les tomes d'une série tiennent sous son nom, à la place qu'occuperait le premier d'entre
+ eux : l'ordre général du catalogue ne bouge pas, on ne perd donc pas un livre qu'on
+ cherchait à sa lettre. Le nom du bloc MÈNE À LA SÉRIE, où figurent aussi les tomes qu'on
+ ne possède pas — ce que le catalogue, lui, ne peut pas montrer.
+ *@
- @foreach (var livre in _livres)
+ @foreach (var entree in Entrees)
{
-
}
@@ -175,6 +220,25 @@ else if (_livres is not null)
public int? AuteurId { get; set; }
private IReadOnlyList? _livres;
+
+ ///
+ /// Les séries du foyer, pour regrouper leurs tomes. null = pas encore lues.
+ ///
+ ///
+ /// ⚠️ Elles ne sont lues qu'une fois par visite (et à chaque bascule du réseau), pas à
+ /// chaque chargement du catalogue : celui-ci se relit à chaque frappe dans la recherche, et
+ /// une requête de séries par caractère serait du gâchis pour une liste qui ne bouge pas
+ /// pendant qu'on tape. Le prix est qu'un tome rattaché depuis un autre écran n'apparaît
+ /// groupé qu'au prochain passage ici.
+ ///
+ private IReadOnlyList? _series;
+
+ /// Regroupement actif — l'utilisateur peut le défaire, rien ne disparaît alors.
+ private bool _grouper = true;
+
+ /// Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés.
+ private IReadOnlyList Entrees =>
+ GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null);
private AuteurDto? _auteur;
private int? _auteurCharge;
private string _recherche = string.Empty;
@@ -238,14 +302,40 @@ else if (_livres is not null)
///
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
+ ///
+ /// Lit les séries, et se contente de ne pas grouper si elles manquent.
+ ///
+ ///
+ /// ⚠️ Une lecture ratée ne doit rien coûter au catalogue : sans série, la liste s'affiche à
+ /// plat, ce qui est exactement l'écran d'avant. Hors-ligne, la lecture retombe d'elle-même
+ /// sur l'instantané « series ».
+ ///
+ private async Task ChargerSeriesAsync()
+ {
+ try
+ {
+ _series = await Api.ListerSeriesAsync();
+ }
+ catch (Exception)
+ {
+ _series = null;
+ }
+ }
+
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
+ await ChargerSeriesAsync();
await ChargerAsync();
StateHasChanged();
});
protected override async Task OnParametersSetAsync()
{
+ if (_series is null)
+ {
+ await ChargerSeriesAsync();
+ }
+
if (_auteurCharge != AuteurId)
{
_auteurCharge = AuteurId;
diff --git a/MaBibli.Client/Services/GroupementCatalogue.cs b/MaBibli.Client/Services/GroupementCatalogue.cs
new file mode 100644
index 0000000..6ee205f
--- /dev/null
+++ b/MaBibli.Client/Services/GroupementCatalogue.cs
@@ -0,0 +1,123 @@
+using MaBibli.Shared.Dtos;
+using MaBibli.Shared.Textes;
+
+namespace MaBibli.Client.Services;
+
+///
+/// Une entrée du catalogue : un livre seul, ou les tomes d'une même série sous son nom.
+///
+public sealed record EntreeCatalogue
+{
+ /// La série qui coiffe le bloc, ou null pour un livre seul.
+ public SerieDto? Serie { get; init; }
+
+ /// Les livres de l'entrée : un seul hors série, les tomes visibles sinon.
+ public required IReadOnlyList Livres { get; init; }
+
+ public bool EstGroupe => Serie is not null;
+}
+
+///
+/// Regroupe les livres du catalogue par série.
+///
+///
+/// Le bloc se place là où son premier tome serait tombé dans l'ordre du catalogue
+/// (choisi avec l'utilisateur le 2026-08-22) : l'ordre général reste celui qu'on connaît, et
+/// l'on retrouve « La Légende de Drizzt » entre « Dracula » et « Dune ». À l'intérieur, les
+/// tomes suivent l'ordre de lecture, seul ordre qui ait un sens pour une saga — c'est
+/// même la raison d'être de ElementSerie.Position.
+///
+/// ⚠️ Rien n'est jamais masqué ni déplacé hors de la liste. Un livre filtré reste absent,
+/// un livre visible reste visible : le regroupement ne fait que rassembler, et l'écran offre de
+/// le défaire. C'est la même règle que le grisage de la bibliographie — on marque, on ne cache
+/// pas.
+///
+///
+/// ⚠️ Un livre peut appartenir à plusieurs séries (le modèle l'autorise, sans unicité sur
+/// LivreId seul). Il n'apparaît pourtant qu'une fois : le dupliquer ferait mentir le
+/// compteur du catalogue et donnerait deux cartes du même exemplaire. La série retenue est la
+/// première par ordre alphabétique — un critère explicable, à défaut d'être le bon dans
+/// tous les cas ; sa fiche livre, elle, les montre toutes.
+///
+///
+public static class GroupementCatalogue
+{
+ public static IReadOnlyList Grouper(
+ IReadOnlyList livres, IReadOnlyList? series)
+ {
+ if (series is null || series.Count == 0)
+ {
+ return [.. livres.Select(l => new EntreeCatalogue { Livres = [l] })];
+ }
+
+ var place = PlaceDesLivres(series);
+
+ // Les tomes s'accumulent dans le brouillon du groupe, créé à la position de son PREMIER
+ // tome rencontré : c'est ce qui range le bloc là où l'ordre du catalogue l'attend.
+ var brouillons = new List<(SerieDto? Serie, List<(int Position, LivreDto Livre)> Livres)>();
+ var groupes = new Dictionary>();
+
+ foreach (var livre in livres)
+ {
+ if (!place.TryGetValue(livre.Id, out var appartenance))
+ {
+ brouillons.Add((null, [(0, livre)]));
+ continue;
+ }
+
+ if (!groupes.TryGetValue(appartenance.Serie.Id, out var tomes))
+ {
+ tomes = [];
+ groupes[appartenance.Serie.Id] = tomes;
+ brouillons.Add((appartenance.Serie, tomes));
+ }
+
+ tomes.Add((appartenance.Position, livre));
+ }
+
+ return
+ [
+ .. brouillons.Select(b => new EntreeCatalogue
+ {
+ Serie = b.Serie,
+ Livres =
+ [
+ .. b.Livres
+ .OrderBy(t => t.Position)
+ .ThenBy(t => t.Livre.Id)
+ .Select(t => t.Livre),
+ ],
+ }),
+ ];
+ }
+
+ ///
+ /// À quelle série — et à quelle place — appartient chaque livre rattaché.
+ ///
+ ///
+ /// ⚠️ Les séries sont parcourues dans l'ordre alphabétique de leur titre normalisé, et le
+ /// TryAdd garde donc la première : sans cet ordre, la série retenue pour un
+ /// livre rattaché deux fois dépendrait de l'ordre où l'API rend les séries, c'est-à-dire de
+ /// rien de compréhensible.
+ ///
+ private static Dictionary PlaceDesLivres(
+ IReadOnlyList series)
+ {
+ var place = new Dictionary();
+
+ foreach (var serie in series
+ .OrderBy(s => NormalisationTexte.Normaliser(s.Titre), StringComparer.Ordinal)
+ .ThenBy(s => s.Id))
+ {
+ foreach (var element in serie.Elements)
+ {
+ if (element.LivreId is { } livreId)
+ {
+ place.TryAdd(livreId, (serie, element.Position));
+ }
+ }
+ }
+
+ return place;
+ }
+}
diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css
index 4df390e..65306f1 100644
--- a/MaBibli.Client/wwwroot/css/app.css
+++ b/MaBibli.Client/wwwroot/css/app.css
@@ -402,6 +402,55 @@ body {
padding: 0;
}
+/* --- Livres d'une même série, rassemblés au catalogue (2026-08-22) ---
+ Le bloc se pose là où son premier tome serait tombé dans l'ordre du catalogue : il ne
+ déplace donc rien, il rassemble. Le trait à gauche est ce qui fait lire les cartes comme
+ « dedans » plutôt que comme la suite de la liste. */
+
+.groupe-serie {
+ margin: 0.5rem 0;
+ border-left: 3px solid var(--mb-accent);
+ padding-left: 0.6rem;
+}
+
+/* Le nom mène à la série : c'est le seul endroit du catalogue qui montre aussi ce qui manque. */
+.groupe-serie-titre {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.4rem;
+ padding: 0.35rem 0;
+ color: var(--mb-texte);
+ text-decoration: none;
+ font-weight: 600;
+}
+
+.groupe-serie-titre:hover .groupe-serie-nom,
+.groupe-serie-titre:focus-visible .groupe-serie-nom {
+ text-decoration: underline;
+}
+
+.groupe-serie-nom {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.groupe-serie-compte {
+ color: var(--mb-texte-doux);
+ font-size: 0.85rem;
+ font-weight: 400;
+}
+
+/* Une case à cocher parmi des rangées de segments : elle doit se lire comme un réglage, pas
+ comme un filtre de plus. */
+.bascule {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0.25rem 0;
+ font-size: 0.9rem;
+}
+
.carte-livre,
.carte-candidat {
display: flex;
diff --git a/MaBibli.Tests/GroupementCatalogueTests.cs b/MaBibli.Tests/GroupementCatalogueTests.cs
new file mode 100644
index 0000000..f47efb5
--- /dev/null
+++ b/MaBibli.Tests/GroupementCatalogueTests.cs
@@ -0,0 +1,136 @@
+using MaBibli.Client.Services;
+using MaBibli.Shared.Dtos;
+
+namespace MaBibli.Tests;
+
+///
+/// Regroupement des tomes d'une série au catalogue (2026-08-22).
+///
+///
+/// L'écran n'est pas éprouvable ici, mais la règle l'est : ces tests portent sur ce qui a été
+/// décidé — la place du bloc, l'ordre des tomes à l'intérieur, et le fait que rien ne
+/// disparaisse ni ne se duplique.
+///
+public class GroupementCatalogueTests
+{
+ private static LivreDto Livre(int id, string titre) =>
+ new()
+ {
+ Id = id,
+ Titre = titre,
+ Format = MaBibli.Shared.Entites.Format.Physique,
+ DateAjout = DateTime.UtcNow,
+ };
+
+ private static SerieDto Serie(int id, string titre, params (int Position, int LivreId)[] tomes) =>
+ new()
+ {
+ Id = id,
+ Titre = titre,
+ Elements =
+ [
+ .. tomes.Select(t => new ElementSerieDto
+ {
+ Id = t.LivreId,
+ Position = t.Position,
+ Titre = $"tome {t.Position}",
+ LivreId = t.LivreId,
+ }),
+ ],
+ };
+
+ ///
+ /// ⚠️ Le cas qui a tranché la décision : le bloc se pose à la place du PREMIER tome, pour
+ /// que l'ordre général du catalogue ne bouge pas.
+ ///
+ [Fact]
+ public void Le_bloc_prend_la_place_de_son_premier_tome()
+ {
+ List catalogue =
+ [
+ Livre(1, "Dracula"),
+ Livre(2, "L'Éclat de cristal"),
+ Livre(3, "Dune"),
+ Livre(4, "Terre natale"),
+ ];
+
+ var entrees = GroupementCatalogue.Grouper(
+ catalogue, [Serie(9, "La Légende de Drizzt", (1, 2), (0, 4))]);
+
+ Assert.Collection(
+ entrees,
+ e => Assert.Equal("Dracula", Assert.Single(e.Livres).Titre),
+ e =>
+ {
+ Assert.Equal("La Légende de Drizzt", e.Serie!.Titre);
+
+ // À l'intérieur, l'ordre est celui de LECTURE, pas celui du catalogue.
+ Assert.Equal(["Terre natale", "L'Éclat de cristal"], e.Livres.Select(l => l.Titre));
+ },
+ e => Assert.Equal("Dune", Assert.Single(e.Livres).Titre));
+ }
+
+ /// Aucun livre ne disparaît, aucun n'apparaît deux fois : c'est la règle du catalogue.
+ [Fact]
+ public void Le_regroupement_ne_perd_ni_ne_duplique_aucun_livre()
+ {
+ List catalogue = [Livre(1, "A"), Livre(2, "B"), Livre(3, "C")];
+
+ // Le livre 2 est rattaché à DEUX séries — le modèle l'autorise.
+ var entrees = GroupementCatalogue.Grouper(
+ catalogue, [Serie(1, "Zeta", (0, 2)), Serie(2, "Alpha", (0, 2), (1, 3))]);
+
+ Assert.Equal([1, 2, 3], entrees.SelectMany(e => e.Livres).Select(l => l.Id).Order());
+ }
+
+ ///
+ /// ⚠️ La série retenue est la première par ordre alphabétique, et non celle que l'API a
+ /// rendue en premier : sans cela, le bloc changerait de nom d'un chargement à l'autre.
+ ///
+ [Fact]
+ public void Un_livre_de_deux_series_est_range_sous_la_premiere_alphabetiquement()
+ {
+ var entrees = GroupementCatalogue.Grouper(
+ [Livre(2, "B")], [Serie(1, "Zeta", (0, 2)), Serie(2, "Alpha", (0, 2))]);
+
+ Assert.Equal("Alpha", Assert.Single(entrees).Serie!.Titre);
+ }
+
+ /// Sans série connue — hors-ligne sans instantané, ou groupement défait — la liste est plate.
+ [Fact]
+ public void Sans_series_la_liste_reste_celle_du_catalogue()
+ {
+ List catalogue = [Livre(1, "A"), Livre(2, "B")];
+
+ var entrees = GroupementCatalogue.Grouper(catalogue, null);
+
+ Assert.All(entrees, e => Assert.Null(e.Serie));
+ Assert.Equal(["A", "B"], entrees.Select(e => e.Livres[0].Titre));
+ }
+
+ ///
+ /// Un seul tome possédé forme quand même un bloc : c'est ce qui dit à quoi ce livre
+ /// appartient, et ce qui mène à la série où figurent les tomes manquants.
+ ///
+ [Fact]
+ public void Un_seul_tome_possede_forme_quand_meme_un_bloc()
+ {
+ var entrees = GroupementCatalogue.Grouper(
+ [Livre(4, "Terre natale")], [Serie(9, "L'Elfe noir", (0, 4), (1, 5))]);
+
+ Assert.Equal("L'Elfe noir", Assert.Single(entrees).Serie!.Titre);
+ }
+
+ ///
+ /// Les tomes filtrés restent absents : le regroupement rassemble ce que la liste contient,
+ /// il ne va pas rechercher ce qu'un filtre a écarté.
+ ///
+ [Fact]
+ public void Un_filtre_ne_ramene_pas_les_tomes_ecartes()
+ {
+ var entrees = GroupementCatalogue.Grouper(
+ [Livre(5, "L'Éclat de cristal")], [Serie(9, "Drizzt", (0, 4), (1, 5))]);
+
+ Assert.Equal(["L'Éclat de cristal"], Assert.Single(entrees).Livres.Select(l => l.Titre));
+ }
+}