Consulter la bibliotheque hors-ligne depuis un cache IndexedDB

Le piege que CLAUDE.md signale : en Blazor WebAssembly le code tourne dans le
navigateur alors que SQLite vit sur le serveur, et le service worker ne met en
cache que les assets. Sans travail explicite, l'application demarre hors-ligne
et affiche une bibliotheque vide.

- js/cache-hors-ligne.js : instantanes JSON dans IndexedDB, et surveillance des
  bascules online/offline. Aucune logique metier.
- CacheHorsLigne / EtatReseau : lecture-ecriture des instantanes, et etat reseau
  combinant navigator.onLine (fiable seulement par sa negation) avec le sort
  reel des appels HTTP.
- ServiceLivresApi : les lectures retombent sur le cache, les ecritures sont
  refusees. Le catalogue entier est memorise, pas les reponses filtrees : c'est
  ce qui rend la recherche hors-ligne possible sur tout le fonds.
- FiltreLivresLocal : pendant navigateur de FiltreLivres, avec un test qui
  confronte les deux implementations sur les memes donnees.
- Interface : pastille et bandeau d'etat avec la date de synchronisation, et
  actions d'ecriture desactivees avec leur raison plutot que boutons morts.
- js/mise-a-jour.js : les empreintes WASM etant desactivees, le service worker
  est le seul cache-busting du projet. L'enregistrement journalise desormais ses
  echecs, et un bandeau propose la nouvelle version sans attendre la fermeture
  de tous les onglets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-18 13:59:19 +02:00
co-authored by Claude Opus 5
parent 3a5a3a7763
commit ebc5f95d49
21 changed files with 1420 additions and 82 deletions
@@ -84,9 +84,17 @@
<p class="message-erreur" role="alert">@Erreur</p>
}
@* Un enregistrement impossible se dit AVANT le clic : un bouton qui échoue en silence est
pire que pas de bouton du tout (CLAUDE.md, stratégie hors-ligne). *@
@if (!string.IsNullOrEmpty(MessageBlocage))
{
<p class="message-avertissement" role="status">@MessageBlocage</p>
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(EnCours || string.IsNullOrWhiteSpace(Saisie.Titre))"
disabled="@(EnCours || Bloque || string.IsNullOrWhiteSpace(Saisie.Titre))"
title="@MessageBlocage"
@onclick="Valider">
@(EnCours ? "Enregistrement…" : LibelleValidation)
</button>
@@ -112,6 +120,15 @@
[Parameter] public bool EnCours { get; set; }
/// <summary>
/// Raison pour laquelle l'enregistrement est impossible (typiquement l'absence de réseau).
/// Renseignée, elle désactive la validation et s'affiche : la saisie reste lisible, mais
/// l'utilisateur sait pourquoi il ne peut pas valider.
/// </summary>
[Parameter] public string? MessageBlocage { get; set; }
private bool Bloque => !string.IsNullOrEmpty(MessageBlocage);
[Parameter] public EventCallback OnValider { get; set; }
[Parameter] public EventCallback OnAnnuler { get; set; }
@@ -1,4 +1,6 @@
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@implements IDisposable
@*
Prêts d'un livre : l'état courant, l'action qui s'impose, puis l'historique.
@@ -17,6 +19,38 @@
Un livre numérique ne se prête pas : il n'y a pas d'exemplaire à confier.
</p>
}
else if (!Reseau.EnLigne)
{
@*
L'historique des prêts n'est pas mis en cache : une requête par livre pour une
information rarement consultée. En revanche l'état COURANT vient de la fiche, donc
de l'instantané du catalogue — c'est lui qui répond à « où est ce livre ? », la
seule question qui se pose vraiment sans réseau, devant l'étagère.
Afficher une liste vide ferait croire à « jamais prêté » : on dit ce qu'on sait, et
on dit ce qu'on ignore.
*@
@if (PreteA is { } absent)
{
<p class="bandeau-pret">
<span class="etiquette etiquette-prete">Prêté</span>
à <strong>@absent</strong>
@if (PreteDepuis is { } depuis)
{
@($" depuis le {Jour(depuis)}")
}
</p>
}
else
{
<p class="message-discret">Ce livre était à la maison à la dernière synchronisation.</p>
}
<p class="message-discret">
Hors ligne : l'historique des prêts n'est pas consultable, et prêter ou marquer un
retour demande le réseau.
</p>
}
else if (_prets is null)
{
<p class="message-discret">Chargement…</p>
@@ -115,6 +149,15 @@
/// <summary>Format du livre : un ebook ne se prête pas.</summary>
[Parameter] public Format Format { get; set; }
/// <summary>
/// Emprunteur actuel d'après la fiche. Sert <b>hors-ligne</b>, où l'historique n'est pas
/// consultable mais où l'état courant, lui, vient de l'instantané du catalogue.
/// </summary>
[Parameter] public string? PreteA { get; set; }
/// <summary>Date du prêt en cours, même origine que <see cref="PreteA"/>.</summary>
[Parameter] public DateTime? PreteDepuis { get; set; }
/// <summary>Prévient la page parente qu'un prêt a changé, pour qu'elle rafraîchisse sa fiche.</summary>
[Parameter] public EventCallback OnChangement { get; set; }
@@ -131,6 +174,21 @@
private IReadOnlyList<PretDto> Historique =>
_prets?.Where(p => !p.EstEnCours).ToList() ?? [];
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
/// <summary>Au retour du réseau, l'historique devient consultable : on va le chercher.</summary>
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
if (Reseau.EnLigne)
{
await ChargerAsync();
}
StateHasChanged();
});
public void Dispose() => Reseau.Change -= SurChangementReseau;
protected override async Task OnParametersSetAsync()
{
if (_livreCharge != LivreId)
@@ -143,6 +201,11 @@
private async Task ChargerAsync()
{
if (!Reseau.EnLigne)
{
return; // L'historique n'est pas en cache : rien à demander, l'affichage le dit.
}
try
{
_prets = await Api.ListerPretsLivreAsync(LivreId) ?? [];