Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
JavaScript

TP 20 : Sauvegarder la To-Do List avec localStorage

TP du chapitre 20 : rendez persistante la To-Do List du TP 12. Sauvegarde et chargement robustes avec localStorage et JSON, organisation en modules avec identifiants uniques, filtres et brouillon dans...

0 chapitres 0 QCM Accès gratuit

À propos de ce cours

TP du chapitre 20 : rendez persistante la To-Do List du TP 12. Sauvegarde et chargement robustes avec localStorage et JSON, organisation en modules avec identifiants uniques, filtres et brouillon dans le sessionStorage, synchronisation entre onglets avec l'événement storage, thème mémorisé dans un cookie, export et import des tâches en JSON. Avec version de départ, recette en 12 scénarios, publication sur GitHub Pages et barème.

Présentation du TP
  • Chapitre : 20 – Le stockage des données dans le navigateur.
  • Durée conseillée : 4 heures.
  • Prérequis : les 3 leçons du chapitre 20, le TP 12 (To-Do List), JSON (chapitre 19), VS Code avec Live Server, un compte GitHub.
  • Sujet : la To-Do List du TP 12 fonctionne, mais elle a un gros défaut : toutes les tâches disparaissent quand on recharge la page. Vous allez la rendre persistante avec le localStorage, puis l'améliorer avec le sessionStorage (filtre et brouillon), l'événement storage (plusieurs onglets), un cookie (thème) et un export / import des tâches.

TP 20 : To-Do List sauvegardée avec localStorage, filtres, thème et export

Objectifs :

  • Sauvegarder et recharger un tableau d'objets avec localStorage et JSON.
  • Écrire un code de stockage robuste (données absentes, abîmées, stockage plein).
  • Choisir le bon stockage pour chaque donnée : localStorage, sessionStorage ou cookie.
  • Organiser le code en modules et séparer l'état, le stockage et l'affichage.
Règle du TP : un commit à la fin de chaque partie (au minimum 8 commits), avec un message clair, et le site publié sur GitHub Pages à la fin.
Partie 1 Repartir de la To-Do List du TP 12
  1. Créez sur GitHub un dépôt public tp20-todo-localstorage avec un README.md, clonez-le et ouvrez-le dans VS Code.
  2. Copiez votre To-Do List du TP 12, ou la version de départ ci-dessous, dans cette arborescence :
tp20-todo-localstorage/
├── index.html
├── css/
│   └── style.css
└── js/
    └── app.js

index.html:

<!doctype html>
<html lang="fr">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>To-Do List</title>
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.9/dist/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <link href="css/style.css" rel="stylesheet" />
    <script src="js/app.js" defer></script>
  </head>
  <body class="bg-body-tertiary">
    <main class="container py-4" style="max-width: 640px">
      <h1 class="h3 mb-4">📝 Ma To-Do List</h1>

      <form id="form-tache" class="d-flex gap-2 mb-3">
        <input
          id="champ-tache"
          class="form-control"
          placeholder="Nouvelle tâche…"
          required
          maxlength="120"
          aria-label="Nouvelle tâche"
        />
        <select id="champ-priorite" class="form-select w-auto" aria-label="Priorité">
          <option value="normale">Normale</option>
          <option value="haute">Haute</option>
        </select>
        <button class="btn btn-primary" type="submit">Ajouter</button>
      </form>

      <ul id="liste" class="list-group mb-3"></ul>

      <div class="d-flex justify-content-between align-items-center">
        <span id="compteur" class="text-body-secondary small"></span>
        <button id="btn-vider" class="btn btn-sm btn-outline-danger" type="button">
          Supprimer les tâches terminées
        </button>
      </div>
    </main>
  </body>
</html>

css/style.css:

.tache.faite .texte {
  text-decoration: line-through;
  color: var(--bs-secondary-color);
}

.tache.haute {
  border-left: 4px solid var(--bs-danger);
}

.tache .texte {
  flex: 1;
  cursor: pointer;
  word-break: break-word;
}

js/app.js:

// TP 12 – To-Do List (version de départ du TP 20)
// Problème : tout est perdu quand on recharge la page !

let taches = [];

const form = document.getElementById("form-tache");
const champ = document.getElementById("champ-tache");
const champPriorite = document.getElementById("champ-priorite");
const liste = document.getElementById("liste");
const compteur = document.getElementById("compteur");

function creerTache(texte, priorite) {
  return { id: Date.now(), texte, faite: false, priorite };
}

function afficher() {
  liste.replaceChildren();
  for (const tache of taches) {
    const li = document.createElement("li");
    li.className = `list-group-item d-flex align-items-center gap-2 tache ${tache.priorite}`;
    if (tache.faite) {
      li.classList.add("faite");
    }
    li.dataset.id = tache.id;

    const caseCocher = document.createElement("input");
    caseCocher.type = "checkbox";
    caseCocher.className = "form-check-input m-0";
    caseCocher.checked = tache.faite;

    const texte = document.createElement("span");
    texte.className = "texte";
    texte.textContent = tache.texte;

    const supprimer = document.createElement("button");
    supprimer.type = "button";
    supprimer.className = "btn btn-sm btn-outline-danger supprimer";
    supprimer.textContent = "✕";
    supprimer.setAttribute("aria-label", `Supprimer « ${tache.texte} »`);

    li.append(caseCocher, texte, supprimer);
    liste.append(li);
  }
  const restantes = taches.filter((tache) => !tache.faite).length;
  compteur.textContent = `${restantes} tâche(s) restante(s) sur ${taches.length}`;
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const texte = champ.value.trim();
  if (texte === "") {
    return;
  }
  taches.push(creerTache(texte, champPriorite.value));
  champ.value = "";
  afficher();
});

liste.addEventListener("click", (event) => {
  const li = event.target.closest("li");
  if (!li) {
    return;
  }
  const id = Number(li.dataset.id);
  if (event.target.matches(".supprimer")) {
    taches = taches.filter((tache) => tache.id !== id);
  } else if (event.target.matches("input[type=checkbox], .texte")) {
    const trouvee = taches.find((tache) => tache.id === id);
    trouvee.faite = !trouvee.faite;
  }
  afficher();
});

document.getElementById("btn-vider").addEventListener("click", () => {
  taches = taches.filter((tache) => !tache.faite);
  afficher();
});

afficher();
  1. Lancez la page avec Live Server, ajoutez trois tâches, cochez-en une, puis appuyez sur F5. Constatez le problème.
  2. Relisez le code et expliquez dans le README.md (3 ou 4 lignes) où sont rangées les tâches et pourquoi elles disparaissent.

Commit : git add . puis git commit -m "Partie 1 : To-Do List de départ" et git push.

Partie 2 Sauvegarder les tâches
  1. Créez une constante CLE_TACHES = "todo:taches:v1". Le préfixe todo: évite les conflits avec d'autres applications du même site, et v1 permettra de faire évoluer le format plus tard.
  2. Écrivez une fonction sauvegarderTaches() qui enregistre le tableau taches en JSON dans le localStorage.
  3. Repérez tous les endroits du code qui modifient le tableau (ajout, case cochée, suppression, suppression des tâches terminées) et appelez sauvegarderTaches() après chaque modification.
  4. Pour ne rien oublier, regroupez ces trois actions (modifier le tableau, sauvegarder, afficher) dans une seule fonction mettreAJour(nouvellesTaches), et utilisez-la partout :
function mettreAJour(nouvellesTaches) {
  taches = nouvellesTaches;
  sauvegarderTaches(taches);
  afficher();
}

// Exemple : suppression
mettreAJour(taches.filter((tache) => tache.id !== id));

Test : ajoutez des tâches et vérifiez dans DevTools → Application → Local storage que la clé todo:taches:v1 contient bien un tableau JSON qui évolue à chaque action.

Commit : "Partie 2 : sauvegarde dans le localStorage".

Partie 3 Recharger les tâches, sans jamais planter
  1. Écrivez chargerTaches() qui renvoie le tableau enregistré, ou un tableau vide si la clé n'existe pas. Au démarrage : let taches = chargerTaches();.
  2. Rendez-la robuste:
    • entourez JSON.parse() d'un try / catch (texte abîmé) ;
    • si le résultat n'est pas un tableau (Array.isArray()), renvoyez [];
    • écrivez estTacheValide(tache) qui vérifie que id est une chaîne, texte une chaîne non vide, faite un booléen et priorite l'une des valeurs autorisées, et ne gardez que les tâches valides (filter).
  3. Faites renvoyer à sauvegarderTaches() true ou false (try / catch autour de setItem()). En cas d'échec, affichez dans une zone #message (alerte Bootstrap) : « ⚠️ Sauvegarde impossible : vos modifications seront perdues à la fermeture. »

Tests (dans la console, puis F5 après chacun) :

  • localStorage.setItem("todo:taches:v1", "{abc"): la liste est vide, sans erreur rouge.
  • localStorage.setItem("todo:taches:v1", '[{"texte":42}, null]'): liste vide, sans erreur.
  • Stockage plein : Storage.prototype.setItem = () => { throw new DOMException("plein", "QuotaExceededError"); }, puis ajoutez une tâche : le message s'affiche.

Commit : "Partie 3 : chargement robuste".

Partie 4 Organiser le code en modules

Le fichier app.js devient long. Séparez-le en trois modules ES (<script type="module" src="js/app.js">) :

FichierContenuAccès
js/taches.jsFonctions « pures » : creerTache, estTacheValide, basculer, supprimer, supprimerTerminees, filtrerNi DOM, ni stockage
js/stockage.jschargerTaches, sauvegarderTaches (et plus tard le filtre, le brouillon, le cookie)Stockage uniquement
js/app.jsÉtat, affichage et événementsDOM

Profitez-en pour corriger deux défauts de la version de départ :

  • Identifiants : Date.now() peut donner deux fois le même nombre (ajout très rapide, import de tâches). Utilisez crypto.randomUUID(), qui génère un identifiant unique de 36 caractères. Attention : le dataset renvoie une chaîne, ne la convertissez plus avec Number().
  • Immuabilité : au lieu de modifier une tâche directement (tache.faite = !tache.faite), basculer() renvoie un nouveau tableau avec map() et la décomposition { ...tache, faite: !tache.faite }.

Exemple attendu pour js/taches.js:

// Fonctions « pures » sur le tableau des tâches : elles ne touchent ni au DOM ni au stockage

export const PRIORITES = ["normale", "haute"];

export function creerTache(texte, priorite = "normale") {
  return {
    id: crypto.randomUUID(),
    texte: texte.trim(),
    faite: false,
    priorite: PRIORITES.includes(priorite) ? priorite : "normale",
    creeLe: new Date().toISOString(),
  };
}

export function estTacheValide(tache) {
  return (
    tache !== null &&
    typeof tache === "object" &&
    typeof tache.id === "string" &&
    typeof tache.texte === "string" &&
    tache.texte.trim() !== "" &&
    typeof tache.faite === "boolean" &&
    PRIORITES.includes(tache.priorite)
  );
}

export function basculer(taches, id) {
  return taches.map((tache) => (tache.id === id ? { ...tache, faite: !tache.faite } : tache));
}

export function supprimer(taches, id) {
  return taches.filter((tache) => tache.id !== id);
}

export function supprimerTerminees(taches) {
  return taches.filter((tache) => !tache.faite);
}

export function filtrer(taches, filtre) {
  if (filtre === "actives") {
    return taches.filter((tache) => !tache.faite);
  }
  if (filtre === "terminees") {
    return taches.filter((tache) => tache.faite);
  }
  return taches;
}

Ajoutez aussi la date de création creeLe (format ISO, new Date().toISOString()) : JSON ne sait pas stocker un objet Date.

Commit : "Partie 4 : modules, identifiants uniques".

Partie 5 Filtres et brouillon dans le sessionStorage
  1. Ajoutez trois boutons de filtre Toutes / Actives / Terminées (groupe de boutons Bootstrap avec des attributs data-filtre) et une seule écoute par délégation. Le bouton actif reçoit la classe active et aria-pressed="true". Affichez « Aucune tâche à afficher. » quand la liste filtrée est vide.
  2. Le filtre choisi est mémorisé dans le sessionStorage (clé todo:filtre) : il survit à un rechargement, mais un nouvel onglet repart sur « Toutes ».
  3. Le texte en cours de saisie dans le champ est enregistré à chaque frappe (événement input) dans le sessionStorage (clé todo:brouillon), restauré au chargement, et effacé après l'ajout.
  4. Dans le README.md, expliquez en une phrase pourquoi on a choisi le sessionStorage et non le localStorage pour ces deux données.

Commit : "Partie 5 : filtres et brouillon (sessionStorage)".

Partie 6 Plusieurs onglets à jour
  1. Ouvrez l'application dans deux onglets côte à côte. Ajoutez une tâche dans le premier : le second n'est pas à jour, et s'il modifie à son tour la liste, il écrase la tâche ajoutée. Vérifiez-le.
  2. Écoutez l'événement storage sur window: si event.key vaut CLE_TACHES (ou null après un clear()), rechargez les tâches avec chargerTaches() et réaffichez-les.
  3. Refaites le test : les deux onglets restent synchronisés.

Commit : "Partie 6 : synchronisation entre onglets".

Partie 7 Le thème dans un cookie
  1. Ajoutez en haut à droite un bouton « 🌙 Thème sombre » / « ☀️ Thème clair ». Le thème est appliqué avec l'attribut Bootstrap data-bs-theme="dark" sur l'élément <html> (document.documentElement.dataset.bsTheme).
  2. Le choix est enregistré dans un cookie theme valable un an, avec path=/ et SameSite=Lax. Écrivez lireCookie(nom) et ecrireCookie(nom, valeur, jours) dans stockage.js (voir la leçon sur les cookies).
  3. Au chargement, appliquez le thème lu dans le cookie.
  4. Pourquoi un cookie ici ? Si un jour la page est générée par un serveur (PHP, Symfony…), il pourra lire le cookie et envoyer directement la page dans le bon thème, sans « flash » blanc. Vérifiez dans l'onglet Réseau que le cookie part bien avec la requête de la page.

Commit : "Partie 7 : thème mémorisé dans un cookie".

Partie 8 Exporter et importer ses tâches

Le localStorage peut être effacé (nettoyage du navigateur, changement d'ordinateur). Offrez une sauvegarde à l'utilisateur.

  1. Exporter : un bouton « ⬇️ Exporter (JSON) » télécharge un fichier taches-AAAA-MM-JJ.json:
const blob = new Blob([JSON.stringify(taches, null, 2)], { type: "application/json" });
const lien = document.createElement("a");
lien.href = URL.createObjectURL(blob);
lien.download = `taches-${new Date().toISOString().slice(0, 10)}.json`;
lien.click();
URL.revokeObjectURL(lien.href);
  1. Importer : un <input type="file" accept=".json"> (caché dans un <label> stylé en bouton). À l'événement change, lisez le fichier avec await fichier.text(), puis :
    • vérifiez que c'est un tableau de tâches valides, sinon affichez « ❌ Ce fichier n'est pas un export valide de la To-Do List. » ;
    • ajoutez seulement les tâches dont l'id n'existe pas encore (utilisez un Set) ;
    • affichez « ✅ 3 tâche(s) importée(s). ».
  2. Affichez en bas à droite la taille de la sauvegarde : new Blob([texte]).size donne le nombre d'octets.

Commit : "Partie 8 : export et import JSON".

Partie 9 Recette, publication et README

9.1 – Recette. Recopiez ce tableau dans le README.md et complétez la colonne « Résultat » :

N°ScénarioRésultat attenduRésultat
R1Ajouter 3 tâches, en cocher une, F5Les 3 tâches, l'état coché et la priorité sont conservés
R2Fermer le navigateur, le rouvrirLes tâches sont toujours là
R3Filtre « Actives », F5, puis nouvel ongletFiltre conservé après F5 ; « Toutes » dans le nouvel onglet
R4Taper un texte sans valider, F5Le texte est toujours dans le champ
R5Deux onglets : ajout dans l'unL'autre se met à jour
R6Thème sombre, fermer, rouvrirThème sombre conservé (cookie theme=dark)
R7JSON abîmé dans le localStorage, F5Liste vide, aucune erreur dans la console
R8Stockage plein simulé, ajoutMessage d'avertissement
R9Exporter, vider le localStorage, importerToutes les tâches reviennent
R10Importer deux fois le même fichier« 0 tâche(s) importée(s) », pas de doublon
R11Importer un fichier JSON quelconqueMessage d'erreur, liste inchangée
R12Tâche <img src=x onerror=alert(1)>, F5Le texte s'affiche tel quel, aucune alerte

9.2 – Publication. Publiez le site avec GitHub Pages (Settings → Pages → Deploy from a branch → main / root). Remarquez que les tâches enregistrées en local (Live Server) ne sont pas visibles sur le site publié : ce n'est pas la même origine.

9.3 – README. Présentation, lien du site, capture d'écran, tableau « Donnée → stockage choisi → justification » (tâches, filtre, brouillon, thème), tableau de recette.

Commit : "Partie 9 : recette, README et publication".

Bonus Pour aller plus loin
  • Migration : si l'ancienne clé taches du TP 12 existe (tableau de textes ou d'objets avec des id numériques), convertissez ses données au nouveau format v1, enregistrez-les, puis supprimez l'ancienne clé.
  • Édition : un double-clic sur le texte le transforme en champ de saisie ; Entrée enregistre, Échap annule.
  • Échéance : ajoutez une date limite (<input type="date">) ; les tâches en retard s'affichent en rouge.
  • Version IndexedDB : sur une branche Git indexeddb, remplacez stockage.js par une version utilisant IndexedDB (un magasin taches avec keyPath: "id"). Seul ce module doit changer, mais ses fonctions deviennent asynchrones : adaptez app.js.
Livrables et barème
  • Le lien du dépôt GitHub (au minimum 8 commits) et le lien du site publié.
  • Le README.md avec les explications demandées et le tableau de recette complété.
CritèrePoints
Sauvegarde après chaque modification, fonction mettreAJour() (partie 2)3
Chargement robuste : JSON abîmé, validation, stockage plein (partie 3)3
Modules, fonctions pures, identifiants uniques (partie 4)3
Filtres et brouillon dans le sessionStorage (partie 5)2
Synchronisation entre onglets (partie 6)2
Thème dans un cookie avec les bons attributs (partie 7)2
Export et import JSON avec contrôle et sans doublon (partie 8)3
Qualité : aucun innerHTML avec des données, recette, README, commits réguliers, site publié2
Total20
Bonus+2
Conseil : gardez DevTools ouvert sur l'onglet Application pendant tout le TP : vous y voyez en direct le localStorage, le sessionStorage et les cookies, et vous pouvez les modifier pour tester les cas difficiles.