Leçon 14 / 15

Ajouter progression et persistance

25 min JavaScript — DOM, événements et asynchrone Gratuit

Objectif : Calculer la part de tâches terminées et conserver les modifications après rechargement.

Quand vous ajoutez, terminez ou supprimez une tâche, l’avancement affiché doit rester cohérent avec la liste. Il doit aussi survivre à une actualisation de la page. Vous allez calculer la progression depuis le tableau d’état, puis déclencher son affichage et l’enregistrement des données à chaque mise à jour.

La règle de calcul est simple : le pourcentage correspond au nombre de tâches terminées divisé par le nombre total, multiplié par 100. Lorsque la liste est vide, le taux vaut zéro ; ce cas évite une division par zéro et correspond à la valeur initiale de la barre. La progression ne constitue pas une donnée indépendante : elle est recalculée depuis les tâches afin de ne pas créer deux états susceptibles de diverger.

Calculer et afficher l’avancement

Le projet dispose déjà des références aux éléments de progression, sélectionnées dans la leçon sur les éléments de l’interface. La fonction calculerProgression ne dépend que du tableau reçu : elle compte les tâches dont terminee vaut true et renvoie aussi le total. La fonction afficherProgression reporte ces résultats dans le texte et dans la propriété value de l’élément progress.

JavaScript
const cleStockage = "gestionnaire-taches";
const versionStockage = 1;
const urlTaches = "https://jsonplaceholder.typicode.com/todos?_limit=5";

const formulaire = document.querySelector("[data-formulaire]");
const champ = document.querySelector("[name='titre']");
const liste = document.querySelector("[data-liste]");
const modele = document.querySelector("[data-modele-tache]");
const message = document.querySelector("[data-message]");
const boutonCharger = document.querySelector("[data-charger]");
const boutonsFiltres = document.querySelectorAll("[data-filtre]");
const progressionTexte = document.querySelector("[data-progression-texte]");
const progressionBarre = document.querySelector("[data-progression]");

if (!formulaire || !champ || !liste || !modele || !message || !boutonCharger || boutonsFiltres.length === 0 || !progressionTexte || !progressionBarre) {
  throw new Error("Un élément nécessaire du gestionnaire est absent de la page.");
}

let taches = restaurerTaches();
let filtreActif = "toutes";

function tacheValide(tache) {
  return tache !== null
    && typeof tache === "object"
    && typeof tache.id === "string"
    && typeof tache.titre === "string"
    && typeof tache.terminee === "boolean";
}

function restaurerTaches() {
  try {
    const valeur = localStorage.getItem(cleStockage);
    if (valeur === null) return [];

    const donnees = JSON.parse(valeur);
    const tableau = Array.isArray(donnees)
      ? donnees
      : donnees?.version === versionStockage && Array.isArray(donnees.taches)
        ? donnees.taches
        : [];
    return tableau.filter(tacheValide);
  } catch (erreur) {
    console.error("Impossible de restaurer les tâches.", erreur);
    return [];
  }
}

function enregistrerTaches() {
  try {
    localStorage.setItem(cleStockage, JSON.stringify({ version: versionStockage, taches }));
  } catch (erreur) {
    message.textContent = "Les tâches ne peuvent pas être enregistrées dans ce navigateur.";
    console.error("Impossible d'enregistrer les tâches.", erreur);
  }
}

function calculerProgression(tachesCourantes) {
  const total = tachesCourantes.length;
  const terminees = tachesCourantes.filter(function (tache) {
    return tache.terminee;
  }).length;
  const pourcentage = total === 0 ? 0 : Math.round((terminees / total) * 100);
  return { total, terminees, pourcentage };
}

function afficherProgression() {
  const progression = calculerProgression(taches);
  progressionTexte.textContent = `${progression.terminees} tâche${progression.terminees === 1 ? "" : "s"} terminée${progression.terminees === 1 ? "" : "s"} sur ${progression.total}`;
  progressionBarre.value = progression.pourcentage;
  progressionBarre.textContent = `${progression.pourcentage}%`;
}

function tachesVisibles() {
  if (filtreActif === "actives") return taches.filter(function (tache) { return !tache.terminee; });
  if (filtreActif === "terminees") return taches.filter(function (tache) { return tache.terminee; });
  return taches;
}

function creerElementTache(tache) {
  const fragment = modele.content.cloneNode(true);
  const element = fragment.querySelector(".atelier-tache");
  const caseTache = fragment.querySelector("[data-action='basculer']");
  const titre = fragment.querySelector("[data-titre]");
  element.dataset.id = tache.id;
  caseTache.checked = tache.terminee;
  titre.textContent = tache.titre;
  return fragment;
}

function afficherTaches() {
  liste.replaceChildren();
  tachesVisibles().forEach(function (tache) {
    liste.append(creerElementTache(tache));
  });
}

function afficherTout() {
  afficherTaches();
  afficherProgression();
}

function mettreAJourTaches(nouvellesTaches) {
  taches = nouvellesTaches;
  enregistrerTaches();
  afficherTout();
}

function traiterSoumission(evenement) {
  evenement.preventDefault();
  const donnees = new FormData(formulaire);
  const titre = String(donnees.get("titre") ?? "").trim();
  if (titre === "") return;
  mettreAJourTaches([...taches, { id: crypto.randomUUID(), titre, terminee: false }]);
  formulaire.reset();
  champ.focus();
  message.textContent = "Tâche ajoutée.";
}

function traiterActionListe(evenement) {
  const commande = evenement.target.closest("[data-action]");
  const element = evenement.target.closest("[data-id]");
  if (!commande || !element || !liste.contains(element)) return;
  const id = element.dataset.id;
  if (commande.dataset.action === "basculer") {
    mettreAJourTaches(taches.map(function (tache) {
      return tache.id === id ? { ...tache, terminee: commande.checked } : tache;
    }));
  }
  if (commande.dataset.action === "supprimer") {
    mettreAJourTaches(taches.filter(function (tache) { return tache.id !== id; }));
  }
}

function traiterFiltre(evenement) {
  const bouton = evenement.target.closest("[data-filtre]");
  if (!bouton) return;
  filtreActif = bouton.dataset.filtre;
  boutonsFiltres.forEach(function (element) {
    element.setAttribute("aria-pressed", String(element === bouton));
  });
  afficherTaches();
}

function convertirTacheDistante(donnee) {
  if (!donnee || typeof donnee.id !== "number" || typeof donnee.title !== "string" || typeof donnee.completed !== "boolean") return null;
  return { id: `distant-${donnee.id}`, titre: donnee.title, terminee: donnee.completed };
}

async function chargerTaches() {
  const reponse = await fetch(urlTaches);
  if (!reponse.ok) throw new Error(`Réponse HTTP ${reponse.status}`);
  const donnees = await reponse.json();
  if (!Array.isArray(donnees)) throw new Error("Le format reçu est invalide.");
  return donnees.map(convertirTacheDistante).filter(Boolean);
}

async function traiterChargement() {
  boutonCharger.disabled = true;
  message.textContent = "Chargement des tâches…";
  try {
    const tachesDistantes = await chargerTaches();
    const identifiants = new Set(taches.map(function (tache) { return tache.id; }));
    const nouvelles = tachesDistantes.filter(function (tache) { return !identifiants.has(tache.id); });
    mettreAJourTaches([...taches, ...nouvelles]);
    message.textContent = `${nouvelles.length} tâche${nouvelles.length === 1 ? "" : "s"} distante${nouvelles.length === 1 ? "" : "s"} ajoutée${nouvelles.length === 1 ? "" : "s"}.`;
  } catch (erreur) {
    message.textContent = "Le chargement a échoué. Vérifiez votre connexion, puis réessayez.";
    console.error("Échec du chargement des tâches.", erreur);
  } finally {
    boutonCharger.disabled = false;
  }
}

formulaire.addEventListener("submit", traiterSoumission);
liste.addEventListener("click", traiterActionListe);
liste.addEventListener("change", traiterActionListe);
document.querySelector("[data-filtres]")?.addEventListener("click", traiterFiltre);
boutonsFiltres.forEach(function (bouton) {
  bouton.addEventListener("click", traiterFiltre);
});
boutonCharger.addEventListener("click", traiterChargement);
afficherTout();
Aperçu du rendu
Chargement de l'aperçu…

Relier le JavaScript à la page

Voici le HTML de référence utilisé par le fichier JavaScript. Les attributs data-* identifient les éléments fonctionnels ; les classes et la structure restent celles du contrat du projet. La barre possède une valeur maximale de 100, ce qui permet d’y reporter directement le pourcentage calculé.

HTML
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Gestionnaire de tâches</title>
  <link rel="stylesheet" href="style.css">
  <script type="module" src="js/main.js"></script>
</head>
<body class="atelier-page">
  <header class="atelier-entete">
    <h1>Gestionnaire de tâches</h1>
    <p>Organisez vos tâches et suivez votre progression.</p>
  </header>
  <main class="atelier-principal">
    <section class="atelier-progression" aria-labelledby="titre-progression">
      <h2 id="titre-progression">Progression</h2>
      <p><span data-progression-texte>0 tâche terminée sur 0</span></p>
      <progress class="atelier-progression__barre" data-progression max="100" value="0">0%</progress>
    </section>
    <form class="atelier-formulaire" data-formulaire>
      <label for="nouvelle-tache">Nouvelle tâche</label>
      <input class="atelier-champ" id="nouvelle-tache" name="titre" type="text" required>
      <button class="atelier-bouton" type="submit">Ajouter</button>
    </form>
    <div class="atelier-actions">
      <div class="atelier-filtres" data-filtres role="group" aria-label="Filtrer les tâches">
        <button class="atelier-bouton" type="button" data-filtre="toutes" aria-pressed="true">Toutes</button>
        <button class="atelier-bouton" type="button" data-filtre="actives" aria-pressed="false">À faire</button>
        <button class="atelier-bouton" type="button" data-filtre="terminees" aria-pressed="false">Terminées</button>
      </div>
      <button class="atelier-bouton" type="button" data-charger>Charger des tâches</button>
    </div>
    <p class="atelier-message" data-message role="status" aria-live="polite"></p>
    <ul class="atelier-liste" data-liste aria-label="Liste des tâches"></ul>
    <template data-modele-tache>
      <li class="atelier-tache" data-id="">
        <input type="checkbox" data-action="basculer">
        <span data-titre></span>
        <button class="atelier-bouton" type="button" data-action="supprimer">Supprimer</button>
      </li>
    </template>
  </main>
</body>
</html>
Aperçu du rendu
Chargement de l'aperçu…

Le code conserve le format versionné introduit précédemment : un objet avec version et taches. Une ancienne valeur constituée directement d’un tableau reste acceptée, tandis que les entrées invalides sont écartées. En cas d’échec d’écriture — par exemple si le navigateur refuse l’accès au stockage — l’application affiche un message au lieu de laisser croire que la sauvegarde a réussi.

À retenir

  • Calculez le taux depuis le nombre de tâches terminées et le nombre total ; prévoyez explicitement le cas où la liste est vide.
  • Mettez à jour à la fois le texte de progression et la valeur de l’élément progress.
  • Faites passer chaque modification de l’état par une fonction commune qui enregistre puis rafraîchit l’interface.
  • La progression concerne toutes les tâches, même lorsqu’un filtre masque une partie de la liste.
  • Le stockage peut échouer : informez l’utilisateur plutôt que de présenter la sauvegarde comme garantie.

Dans la prochaine leçon, vous vérifierez le parcours complet du gestionnaire et ses cas limites dans le navigateur.

Envie d'aller plus loin ?

Les cours Enovatic sont gratuits et le resteront. Si vous cherchez un accompagnement encadré, nos formations professionnelles sont animées par les mêmes formateurs.