Lesson 3/12 13 min

Project 3: a habit tracker, built with AI

A guided build: a habit tracker that remembers, made with AI. The real prompts, and the review that hardens localStorage, escapes typed text and makes the cells keyboard-usable.

FR EN

Le projet : cocher, et que ça se souvienne

Troisième projet, et une vraie nouveauté : cette fois, l'app va se souvenir de toi. On construit un suivi d'habitudes. Tu ajoutes une habitude (« Lire 10 min »), tu coches tes jours, et un compteur clair (« 4/7 cette semaine ») te montre où tu en es. Tu fermes l'onglet, tu reviens demain : tes coches sont toujours là. C'est tout bête, mais c'est le déclic qui transforme une page en outil.

Le nouvel ingrédient, c'est donc la persistance : on garde des données entre deux visites grâce à localStorage, une petite mémoire intégrée au navigateur. Pas de compte, pas de serveur, pas de base de données. Et c'est exactement là que l'IA écrit du code qui marche le premier jour… et casse le deuxième, parce qu'elle ne se méfie jamais de ce qu'elle relit. On code vite, on relit lentement — tu connais la chanson maintenant.

Détail qui aura son importance : localStorage ne sait stocker que du texte. Pour y ranger une liste d'habitudes, on la transforme en chaîne avec JSON.stringify, et on la relit avec JSON.parse. Retiens ce couple, c'est lui qui va nous jouer des tours.

Prompt 1 : poser le cadre

On cadre la demande comme d'habitude : ce qu'on veut à l'écran (un champ d'ajout, une rangée de cases par habitude) et, surtout, la consigne qui change tout ici — sauvegarder dans localStorage pour que ça persiste.

Crée une mini-app web autonome, un seul fichier HTML (zéro dépendance). Un suivi d'habitudes : un champ pour ajouter une habitude, et pour chaque habitude une rangée de 7 cases (les 7 derniers jours) qu'on peut cocher. Sauvegarde tout dans localStorage pour que ça persiste. Bilingue FR/EN. Code simple et lisible.

L'IA livre une version qui fonctionne à l'écran. Mais le chargement et l'affichage cachent trois pièges.

// Ce que l'IA écrit spontanément
var habits = JSON.parse(localStorage.getItem('habits'));   // ⚠️ casse si vide/corrompu

function render() {
  list.innerHTML = habits.map(function (h) {
    return '<div class="habit">' + h.name + '</div>';  // ⚠️ nom injecté en HTML
  }).join('');
}

Ma relecture humaine : 3 trucs que l'IA a laissés passer

Cette fois, deux des trois problèmes ne se voient pas du tout au premier coup d'œil : ils attendent le bon moment pour casser (le deuxième lancement, un nom un peu spécial). C'est typique du code « qui marche chez moi ». On les débusque un par un.

1. localStorage est fragile au chargement

Au tout premier lancement, localStorage.getItem('habits') renvoie nullJSON.parse(null) et tout plante avant même l'affichage. Et si la donnée est corrompue (un bug passé, une extension), JSON.parse lève une exception. La règle : lire dans un try/catch, et vérifier que c'est bien un tableau avant de l'utiliser. On ne fait jamais confiance au contenu du stockage.

2. Le nom de l'habitude est saisi par l'utilisateur

Cette fois le texte vient d'un champ, pas d'un tableau qu'on contrôle. L'injecter avec innerHTML, c'est ouvrir une faille XSS pour de vrai : tape <img src=x onerror=alert(1)> comme nom et ça s'exécute. On construit les éléments avec createElement et on pose le texte avec textContent.

3. Une case à cocher doit être atteignable au clavier

Les cases sont des <button> (pas des <div>) avec aria-pressed qui reflète l'état coché/décoché, et un aria-label qui dit quelle habitude et quel jour. On navigue et on coche entièrement au clavier.

Détail qui mord : « aujourd'hui ». L'IA utilise souvent toISOString() (UTC), ce qui change de jour avant minuit selon ton fuseau. On calcule la date en heure locale.

Prompt 2 : durcir après relecture

Quatre points relevés (les trois ci-dessus, plus la date), quatre consignes précises à l'IA :

Quatre corrections : (1) lis localStorage dans un try/catch et tombe sur un tableau vide si c'est nul ou corrompu. (2) Construis les habitudes avec createElement + textContent (pas innerHTML) car le nom est saisi par l'utilisateur. (3) Fais des cases <button> avec aria-pressed et aria-label. (4) Calcule "aujourd'hui" en date locale, pas en UTC.
function load() {
  try {
    var raw = localStorage.getItem('habits-data');
    if (!raw) return [];                       // jamais de JSON.parse(null)
    var data = JSON.parse(raw);
    return Array.isArray(data) ? data : [];    // on vérifie la forme
  } catch (e) { return []; }                   // données corrompues : on repart propre
}

// date LOCALE, pas UTC
function ymd(d) {
  return d.getFullYear() + '-' + ('0'+(d.getMonth()+1)).slice(-2) + '-' + ('0'+d.getDate()).slice(-2);
}

Le réflexe transversal des projets précédents revient sous une autre forme : ne jamais faire confiance à une donnée qu'on ne contrôle pas. Avant c'était une couleur aléatoire ; ici c'est le contenu du stockage et un champ texte. Même prudence.

Héberger et tester

  • Fichier statique : on le dépose sur le serveur, c'est en ligne.
  • Ajoute une habitude, coche des jours, recharge la page : tout doit être encore là.
  • Mets <b>test</b> comme nom d'habitude : ça doit s'afficher tel quel, pas en gras (preuve que le XSS est bloqué).
  • Navigue au clavier (Tab + Entrée) pour cocher une case.
  • Console : zéro erreur. Bascule FR / EN.

Le rendu final

Ouvrir le projet en plein écran

Le code complet (et téléchargeable)

Le fichier entier, exactement celui qui tourne au-dessus.

Télécharger le code (.html · 275 lignes)

Voir le code complet
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Suivi d'habitudes</title>
<meta name="description" content="Coche tes habitudes jour après jour, ça se mémorise. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">

<!--
  ============================================================================
  SUIVI D'HABITUDES — un seul fichier : HTML + CSS + JS, zéro dépendance.
    1. <style>  : l'apparence (les cartes d'habitude, les cases des 7 jours).
    2. <body>   : la structure (champ d'ajout + liste des habitudes).
    3. <script> : la logique (ajouter, cocher, SAUVEGARDER dans le navigateur).
  Point clé du projet : la PERSISTANCE. On range les données dans localStorage,
  et on relit toujours ce contenu avec prudence (il peut être vide ou abîmé).
  ============================================================================
-->

<style>
  :root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; --cell:#eef1f4; }
  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; }
  body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
  .app { width:100%; max-width:600px; }
  header { text-align:center; margin-bottom:20px; }
  h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
  .sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
  .lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
  .lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; }
  .lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
  .lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }

  /* La ligne d'ajout (champ + bouton). */
  .add-row { display:flex; gap:10px; margin-bottom:22px; }
  .add-input { flex:1; min-height:48px; padding:0 14px; border:1.5px solid var(--border); border-radius:10px; font:inherit; font-size:1rem; color:var(--ink); }
  .add-input:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
  .btn { min-height:48px; padding:0 20px; border-radius:10px; border:1.5px solid transparent; font:inherit; font-weight:700; cursor:pointer; }
  .btn-primary { background:var(--accent); color:#fff; }
  .btn-primary:hover { background:#1f6a37; }
  .btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }

  /* Une carte d'habitude : son nom, son compteur, et les 7 cases de la semaine. */
  .habit { background:#fff; border:1px solid var(--border); border-radius:14px; padding:16px 16px 14px; margin-bottom:14px; }
  .habit-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:12px; }
  .habit-name { font-weight:700; font-size:1.02rem; word-break:break-word; }
  .habit-streak { font-size:0.78rem; color:var(--accent); font-weight:700; white-space:nowrap; }
  .habit-del { border:0; background:transparent; color:var(--muted); cursor:pointer; font-size:1.1rem; line-height:1; padding:6px; border-radius:6px; }
  .habit-del:hover { color:#a8341f; background:#faf0eb; }
  .habit-del:focus-visible { outline:3px solid rgba(38,125,66,0.4); }
  .days { display:flex; gap:8px; }
  .day { flex:1; display:flex; flex-direction:column; align-items:center; gap:4px; }
  .day-label { font-size:0.66rem; color:var(--muted); text-transform:uppercase; }
  /* Une case = un bouton carré. Vert quand l'habitude est faite ce jour-là. */
  .cell { width:100%; aspect-ratio:1/1; min-height:34px; border:1px solid var(--border); border-radius:8px; background:var(--cell); cursor:pointer; transition:background .15s, transform .1s; }
  .cell[aria-pressed="true"] { background:var(--accent); border-color:var(--accent); }
  .cell.today { box-shadow:inset 0 0 0 2px rgba(38,125,66,0.4); }   /* la case du jour est entourée */
  .cell:hover { transform:scale(1.05); }
  .cell:focus-visible { outline:3px solid rgba(38,125,66,0.5); outline-offset:1px; }
  .empty { text-align:center; color:var(--muted); padding:24px 0; }

  .status { text-align:center; min-height:20px; margin-top:6px; font-size:0.85rem; font-weight:600; color:var(--accent); }
  .credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:26px; }
  .credit a { color:var(--accent); }
  @media (max-width:420px){ .day-label{font-size:0.58rem;} }
  @media (prefers-reduced-motion: reduce){ .cell{transition:none;} }
</style>
</head>
<body>

<main class="app">
  <header>
    <h1 data-i18n="title">Suivi d'habitudes</h1>
    <p class="sub" data-i18n="sub">Coche chaque jour. Tout se mémorise dans ton navigateur.</p>
    <div class="lang-switch" role="group" aria-label="Langue / Language">
      <button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
      <button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
    </div>
  </header>

  <!-- Un <form> : la touche Entrée valide l'ajout, pas seulement le clic sur le bouton. -->
  <form class="add-row" id="add-form">
    <input class="add-input" id="add-input" type="text" maxlength="40" data-i18n-ph="ph" placeholder="Nouvelle habitude (ex. Lire 10 min)" aria-label="Nouvelle habitude">
    <button class="btn btn-primary" type="submit" data-i18n="add">Ajouter</button>
  </form>

  <!-- Zone vide : le JS y construit la liste des habitudes. -->
  <div id="list"></div>

  <p class="status" id="status" role="status" aria-live="polite"></p>
  <p class="credit" data-i18n="credit">Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com</p>
</main>

<script>
(function () {
  'use strict';

  /* ---------------------------------------------------------------------------
     1. LES TEXTES DE L'INTERFACE (traduits FR / EN).
        weekDone est une fonction car le texte dépend d'un nombre ("3/7…").
        days = les initiales des jours, de lundi à dimanche.
     --------------------------------------------------------------------------- */
  var UI = {
    fr: { title:"Suivi d'habitudes", sub:"Coche chaque jour. Tout se mémorise dans ton navigateur.",
      add:"Ajouter", ph:"Nouvelle habitude (ex. Lire 10 min)",
      credit:'Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com',
      empty:"Aucune habitude pour l'instant. Ajoute-en une ci-dessus.",
      weekDone:function(n){ return n + "/7 cette semaine"; }, perfect:"🔥 Semaine parfaite !",
      del:"Supprimer l'habitude ", added:"Habitude ajoutée.", days:['L','M','M','J','V','S','D'] },
    en: { title:"Habit tracker", sub:"Tick each day. Everything is saved in your browser.",
      add:"Add", ph:"New habit (e.g. Read 10 min)",
      credit:'A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com',
      empty:"No habit yet. Add one above.",
      weekDone:function(n){ return n + "/7 this week"; }, perfect:"🔥 Perfect week!",
      del:"Delete habit ", added:"Habit added.", days:['M','T','W','T','F','S','S'] }
  };

  /* ---------------------------------------------------------------------------
     2. LES ÉLÉMENTS DE LA PAGE + LA LANGUE.
     --------------------------------------------------------------------------- */
  var listEl = document.getElementById('list');
  var statusEl = document.getElementById('status');
  var form = document.getElementById('add-form');
  var input = document.getElementById('add-input');

  var lang = 'fr';
  try { lang = localStorage.getItem('habits-lang') || 'fr'; } catch (e) {}
  if (lang !== 'fr' && lang !== 'en') lang = 'fr';

  /* ---------------------------------------------------------------------------
     3. LA SAUVEGARDE — lire et écrire les habitudes dans localStorage.
        localStorage ne stocke que du texte : on passe par JSON.
     --------------------------------------------------------------------------- */

  // Lecture PRUDENTE : au premier lancement c'est vide (null), et un jour ça peut
  // être abîmé. On entoure d'un try/catch et on vérifie que c'est bien un tableau.
  function load() {
    try {
      var raw = localStorage.getItem('habits-data');
      if (!raw) return [];                       // rien encore : on part d'une liste vide (jamais JSON.parse(null))
      var data = JSON.parse(raw);
      return Array.isArray(data) ? data : [];    // forme inattendue : on repart propre
    } catch (e) { return []; }                   // contenu corrompu : pareil, liste vide
  }
  function save(habits) {
    try { localStorage.setItem('habits-data', JSON.stringify(habits)); } catch (e) {}
  }

  var habits = load();   // les habitudes : [{ id, name, days: { '2026-05-28': true, ... } }, ...]

  /* ---------------------------------------------------------------------------
     4. LES OUTILS DATE — travailler avec les 7 derniers jours.
     --------------------------------------------------------------------------- */

  // Transforme une date en clé "AAAA-MM-JJ" en heure LOCALE (et non UTC,
  // ce qui décalerait le "jour" autour de minuit selon le fuseau horaire).
  function ymd(d) {
    return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);
  }
  // Renvoie les n derniers jours, du plus ancien au plus récent (aujourd'hui en dernier).
  function lastDays(n) {
    var out = [], today = new Date();
    for (var i = n - 1; i >= 0; i--) {
      var d = new Date(today); d.setDate(today.getDate() - i); out.push(d);
    }
    return out;
  }
  // Petit identifiant unique pour chaque habitude (pour pouvoir la retrouver/supprimer).
  function uid() { return 'h' + Date.now() + Math.floor(Math.random() * 1000); }

  /* ---------------------------------------------------------------------------
     5. L'AFFICHAGE — reconstruire toute la liste à partir du tableau "habits".
        On efface tout puis on recrée : simple à suivre, et toujours juste.
     --------------------------------------------------------------------------- */
  function render() {
    listEl.innerHTML = '';

    // Cas particulier : aucune habitude -> un message d'invitation.
    if (!habits.length) {
      var p = document.createElement('p'); p.className = 'empty'; p.textContent = UI[lang].empty;
      listEl.appendChild(p); return;
    }

    var days = lastDays(7), todayStr = ymd(new Date());

    habits.forEach(function (h) {
      var card = document.createElement('div'); card.className = 'habit';

      // --- En-tête : le nom, le compteur de la semaine, et le bouton supprimer ---
      var head = document.createElement('div'); head.className = 'habit-head';
      var name = document.createElement('span'); name.className = 'habit-name';
      name.textContent = h.name;   // textContent : le nom est SAISI par l'utilisateur, donc jamais interprété comme du HTML
      var right = document.createElement('div'); right.style.display = 'flex'; right.style.alignItems = 'center'; right.style.gap = '8px';

      // Compteur "X/7" : combien des 7 jours affichés sont cochés.
      var streak = document.createElement('span'); streak.className = 'habit-streak';
      var weekCount = days.reduce(function (acc, d) { return acc + (h.days[ymd(d)] ? 1 : 0); }, 0);
      streak.textContent = weekCount === 7 ? UI[lang].perfect : UI[lang].weekDone(weekCount);

      var del = document.createElement('button'); del.className = 'habit-del'; del.type = 'button';
      del.textContent = '✕'; del.setAttribute('aria-label', UI[lang].del + h.name);
      del.addEventListener('click', function () {
        habits = habits.filter(function (x) { return x.id !== h.id; });   // on retire cette habitude
        save(habits); render();
      });
      right.appendChild(streak); right.appendChild(del);
      head.appendChild(name); head.appendChild(right);

      // --- Les 7 cases de la semaine ---
      var daysEl = document.createElement('div'); daysEl.className = 'days';
      days.forEach(function (d) {
        var key = ymd(d);
        var col = document.createElement('div'); col.className = 'day';
        var lab = document.createElement('span'); lab.className = 'day-label';
        lab.textContent = UI[lang].days[(d.getDay() + 6) % 7];   // getDay(): 0=dimanche ; ce calcul met lundi en tête

        // Une vraie <button> : on peut l'atteindre au clavier et l'activer avec Entrée/Espace.
        var cell = document.createElement('button');
        cell.type = 'button'; cell.className = 'cell' + (key === todayStr ? ' today' : '');
        var done = !!h.days[key];
        cell.setAttribute('aria-pressed', done ? 'true' : 'false');   // état coché/décoché annoncé au lecteur d'écran
        cell.setAttribute('aria-label', h.name + ' — ' + key);
        cell.addEventListener('click', function () {
          if (h.days[key]) delete h.days[key]; else h.days[key] = true;   // on bascule l'état du jour
          save(habits); render();
        });
        col.appendChild(lab); col.appendChild(cell); daysEl.appendChild(col);
      });

      card.appendChild(head); card.appendChild(daysEl); listEl.appendChild(card);
    });
  }

  /* ---------------------------------------------------------------------------
     6. LES ACTIONS — ajouter une habitude, changer de langue.
     --------------------------------------------------------------------------- */
  form.addEventListener('submit', function (e) {
    e.preventDefault();                 // empêche le rechargement de page par défaut du formulaire
    var v = input.value.trim();
    if (!v) return;                     // on ignore un champ vide
    habits.push({ id: uid(), name: v, days: {} });
    save(habits); input.value = ''; statusEl.textContent = UI[lang].added; render();
  });

  function setLang(next) {
    lang = next;
    try { localStorage.setItem('habits-lang', lang); } catch (e) {}
    document.documentElement.lang = lang;
    var t = UI[lang];
    document.querySelectorAll('[data-i18n]').forEach(function (el) {
      var k = el.getAttribute('data-i18n'); if (!t[k]) return;
      if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
    });
    var phEl = document.querySelector('[data-i18n-ph]'); if (phEl) phEl.placeholder = t.ph;   // le placeholder se traduit à part
    document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
      b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
    });
    statusEl.textContent = ''; render();
  }

  /* ---------------------------------------------------------------------------
     7. LE DÉMARRAGE.
     --------------------------------------------------------------------------- */
  document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
    b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
  });

  setLang(lang);   // applique la langue et déclenche le premier affichage
})();
</script>
</body>
</html>

À toi de jouer

  • Affiche les 30 derniers jours en grille (style « contributions »).
  • Une confirmation avant de supprimer une habitude.
  • Un export / import des données en JSON.

À chaque ajout : la donnée chargée est-elle vérifiée ? le texte affiché est-il échappé ? l'action est-elle accessible ?

Projet suivant
Mini-dashboard via une API →

The project: tick it, and have it remember

Third project, and a real first: this time, the app will remember you. We build a habit tracker. You add a habit ("Read 10 min"), you tick your days, and a clear counter ("4/7 this week") shows where you stand. Close the tab, come back tomorrow: your ticks are still there. It's a tiny thing, but it's the click that turns a page into a tool.

The new ingredient is therefore persistence: we keep data between visits thanks to localStorage, a small memory built into the browser. No account, no server, no database. And that's exactly where the AI writes code that works on day one… and breaks on day two, because it never distrusts what it reads back. Code fast, review slowly — you know the tune by now.

A detail that will matter: localStorage can only store text. To save a list of habits in it, we turn it into a string with JSON.stringify, and read it back with JSON.parse. Remember this pair, it's the one that's going to trip us up.

Prompt 1: set the frame

We frame the request as usual: what we want on screen (an add field, a row of boxes per habit) and, above all, the instruction that changes everything here — save to localStorage so it persists.

Create a standalone web mini-app, a single HTML file (zero dependencies). A habit tracker: a field to add a habit, and for each habit a row of 7 boxes (the last 7 days) you can tick. Save everything in localStorage so it persists. Bilingual FR/EN. Simple, readable code.

The AI ships a version that works on screen. But loading and rendering hide three traps.

// What the AI writes by default
var habits = JSON.parse(localStorage.getItem('habits'));   // ⚠️ breaks if empty/corrupt

function render() {
  list.innerHTML = habits.map(function (h) {
    return '<div class="habit">' + h.name + '</div>';  // ⚠️ name injected as HTML
  }).join('');
}

My human review: 3 things the AI let slip

This time, two of the three problems don't show at all at first glance: they wait for the right moment to break (the second launch, a slightly special name). That's textbook "works on my machine" code. We flush them out one by one.

1. localStorage is fragile on load

On the very first launch, localStorage.getItem('habits') returns nullJSON.parse(null) and everything crashes before rendering. And if the data is corrupted (a past bug, an extension), JSON.parse throws. The rule: read inside a try/catch, and check it's actually an array before using it. Never trust storage content.

2. The habit name is typed by the user

This time the text comes from an input, not an array you control. Injecting it with innerHTML opens a real XSS hole: type <img src=x onerror=alert(1)> as a name and it runs. Build elements with createElement and set the text with textContent.

3. A checkbox must be keyboard-reachable

The boxes are <button> (not <div>) with aria-pressed reflecting the ticked/unticked state, and an aria-label saying which habit and which day. You navigate and tick entirely with the keyboard.

A detail that bites: "today". The AI often uses toISOString() (UTC), which flips the day before midnight depending on your timezone. We compute the date in local time.

Prompt 2: harden after review

Four points spotted (the three above, plus the date), four precise instructions to the AI:

Four fixes: (1) read localStorage inside a try/catch and fall back to an empty array if null or corrupt. (2) Build the habits with createElement + textContent (not innerHTML) since the name is user-typed. (3) Make the boxes <button> with aria-pressed and aria-label. (4) Compute "today" in local time, not UTC.
function load() {
  try {
    var raw = localStorage.getItem('habits-data');
    if (!raw) return [];                       // never JSON.parse(null)
    var data = JSON.parse(raw);
    return Array.isArray(data) ? data : [];    // check the shape
  } catch (e) { return []; }                   // corrupt data: start clean
}

// LOCAL date, not UTC
function ymd(d) {
  return d.getFullYear() + '-' + ('0'+(d.getMonth()+1)).slice(-2) + '-' + ('0'+d.getDate()).slice(-2);
}

The cross-cutting reflex from the previous projects comes back in a new shape: never trust data you don't control. Before it was a random color; here it's storage content and a text field. Same caution.

Host and test

  • Static file: drop it on the server, it's online.
  • Add a habit, tick some days, reload the page: everything must still be there.
  • Put <b>test</b> as a habit name: it must show as-is, not in bold (proof XSS is blocked).
  • Navigate with the keyboard (Tab + Enter) to tick a box.
  • Console: zero error. Toggle FR / EN.

The finished result

Open the project full screen

The full code (and downloadable)

The entire file, exactly the one running above.

Download the code (.html · 275 lines)

View the full code
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Suivi d'habitudes</title>
<meta name="description" content="Coche tes habitudes jour après jour, ça se mémorise. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">

<!--
  ============================================================================
  SUIVI D'HABITUDES — un seul fichier : HTML + CSS + JS, zéro dépendance.
    1. <style>  : l'apparence (les cartes d'habitude, les cases des 7 jours).
    2. <body>   : la structure (champ d'ajout + liste des habitudes).
    3. <script> : la logique (ajouter, cocher, SAUVEGARDER dans le navigateur).
  Point clé du projet : la PERSISTANCE. On range les données dans localStorage,
  et on relit toujours ce contenu avec prudence (il peut être vide ou abîmé).
  ============================================================================
-->

<style>
  :root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; --cell:#eef1f4; }
  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; }
  body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
  .app { width:100%; max-width:600px; }
  header { text-align:center; margin-bottom:20px; }
  h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
  .sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
  .lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
  .lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; }
  .lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
  .lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }

  /* La ligne d'ajout (champ + bouton). */
  .add-row { display:flex; gap:10px; margin-bottom:22px; }
  .add-input { flex:1; min-height:48px; padding:0 14px; border:1.5px solid var(--border); border-radius:10px; font:inherit; font-size:1rem; color:var(--ink); }
  .add-input:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
  .btn { min-height:48px; padding:0 20px; border-radius:10px; border:1.5px solid transparent; font:inherit; font-weight:700; cursor:pointer; }
  .btn-primary { background:var(--accent); color:#fff; }
  .btn-primary:hover { background:#1f6a37; }
  .btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }

  /* Une carte d'habitude : son nom, son compteur, et les 7 cases de la semaine. */
  .habit { background:#fff; border:1px solid var(--border); border-radius:14px; padding:16px 16px 14px; margin-bottom:14px; }
  .habit-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:12px; }
  .habit-name { font-weight:700; font-size:1.02rem; word-break:break-word; }
  .habit-streak { font-size:0.78rem; color:var(--accent); font-weight:700; white-space:nowrap; }
  .habit-del { border:0; background:transparent; color:var(--muted); cursor:pointer; font-size:1.1rem; line-height:1; padding:6px; border-radius:6px; }
  .habit-del:hover { color:#a8341f; background:#faf0eb; }
  .habit-del:focus-visible { outline:3px solid rgba(38,125,66,0.4); }
  .days { display:flex; gap:8px; }
  .day { flex:1; display:flex; flex-direction:column; align-items:center; gap:4px; }
  .day-label { font-size:0.66rem; color:var(--muted); text-transform:uppercase; }
  /* Une case = un bouton carré. Vert quand l'habitude est faite ce jour-là. */
  .cell { width:100%; aspect-ratio:1/1; min-height:34px; border:1px solid var(--border); border-radius:8px; background:var(--cell); cursor:pointer; transition:background .15s, transform .1s; }
  .cell[aria-pressed="true"] { background:var(--accent); border-color:var(--accent); }
  .cell.today { box-shadow:inset 0 0 0 2px rgba(38,125,66,0.4); }   /* la case du jour est entourée */
  .cell:hover { transform:scale(1.05); }
  .cell:focus-visible { outline:3px solid rgba(38,125,66,0.5); outline-offset:1px; }
  .empty { text-align:center; color:var(--muted); padding:24px 0; }

  .status { text-align:center; min-height:20px; margin-top:6px; font-size:0.85rem; font-weight:600; color:var(--accent); }
  .credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:26px; }
  .credit a { color:var(--accent); }
  @media (max-width:420px){ .day-label{font-size:0.58rem;} }
  @media (prefers-reduced-motion: reduce){ .cell{transition:none;} }
</style>
</head>
<body>

<main class="app">
  <header>
    <h1 data-i18n="title">Suivi d'habitudes</h1>
    <p class="sub" data-i18n="sub">Coche chaque jour. Tout se mémorise dans ton navigateur.</p>
    <div class="lang-switch" role="group" aria-label="Langue / Language">
      <button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
      <button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
    </div>
  </header>

  <!-- Un <form> : la touche Entrée valide l'ajout, pas seulement le clic sur le bouton. -->
  <form class="add-row" id="add-form">
    <input class="add-input" id="add-input" type="text" maxlength="40" data-i18n-ph="ph" placeholder="Nouvelle habitude (ex. Lire 10 min)" aria-label="Nouvelle habitude">
    <button class="btn btn-primary" type="submit" data-i18n="add">Ajouter</button>
  </form>

  <!-- Zone vide : le JS y construit la liste des habitudes. -->
  <div id="list"></div>

  <p class="status" id="status" role="status" aria-live="polite"></p>
  <p class="credit" data-i18n="credit">Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com</p>
</main>

<script>
(function () {
  'use strict';

  /* ---------------------------------------------------------------------------
     1. LES TEXTES DE L'INTERFACE (traduits FR / EN).
        weekDone est une fonction car le texte dépend d'un nombre ("3/7…").
        days = les initiales des jours, de lundi à dimanche.
     --------------------------------------------------------------------------- */
  var UI = {
    fr: { title:"Suivi d'habitudes", sub:"Coche chaque jour. Tout se mémorise dans ton navigateur.",
      add:"Ajouter", ph:"Nouvelle habitude (ex. Lire 10 min)",
      credit:'Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com',
      empty:"Aucune habitude pour l'instant. Ajoute-en une ci-dessus.",
      weekDone:function(n){ return n + "/7 cette semaine"; }, perfect:"🔥 Semaine parfaite !",
      del:"Supprimer l'habitude ", added:"Habitude ajoutée.", days:['L','M','M','J','V','S','D'] },
    en: { title:"Habit tracker", sub:"Tick each day. Everything is saved in your browser.",
      add:"Add", ph:"New habit (e.g. Read 10 min)",
      credit:'A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com',
      empty:"No habit yet. Add one above.",
      weekDone:function(n){ return n + "/7 this week"; }, perfect:"🔥 Perfect week!",
      del:"Delete habit ", added:"Habit added.", days:['M','T','W','T','F','S','S'] }
  };

  /* ---------------------------------------------------------------------------
     2. LES ÉLÉMENTS DE LA PAGE + LA LANGUE.
     --------------------------------------------------------------------------- */
  var listEl = document.getElementById('list');
  var statusEl = document.getElementById('status');
  var form = document.getElementById('add-form');
  var input = document.getElementById('add-input');

  var lang = 'fr';
  try { lang = localStorage.getItem('habits-lang') || 'fr'; } catch (e) {}
  if (lang !== 'fr' && lang !== 'en') lang = 'fr';

  /* ---------------------------------------------------------------------------
     3. LA SAUVEGARDE — lire et écrire les habitudes dans localStorage.
        localStorage ne stocke que du texte : on passe par JSON.
     --------------------------------------------------------------------------- */

  // Lecture PRUDENTE : au premier lancement c'est vide (null), et un jour ça peut
  // être abîmé. On entoure d'un try/catch et on vérifie que c'est bien un tableau.
  function load() {
    try {
      var raw = localStorage.getItem('habits-data');
      if (!raw) return [];                       // rien encore : on part d'une liste vide (jamais JSON.parse(null))
      var data = JSON.parse(raw);
      return Array.isArray(data) ? data : [];    // forme inattendue : on repart propre
    } catch (e) { return []; }                   // contenu corrompu : pareil, liste vide
  }
  function save(habits) {
    try { localStorage.setItem('habits-data', JSON.stringify(habits)); } catch (e) {}
  }

  var habits = load();   // les habitudes : [{ id, name, days: { '2026-05-28': true, ... } }, ...]

  /* ---------------------------------------------------------------------------
     4. LES OUTILS DATE — travailler avec les 7 derniers jours.
     --------------------------------------------------------------------------- */

  // Transforme une date en clé "AAAA-MM-JJ" en heure LOCALE (et non UTC,
  // ce qui décalerait le "jour" autour de minuit selon le fuseau horaire).
  function ymd(d) {
    return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);
  }
  // Renvoie les n derniers jours, du plus ancien au plus récent (aujourd'hui en dernier).
  function lastDays(n) {
    var out = [], today = new Date();
    for (var i = n - 1; i >= 0; i--) {
      var d = new Date(today); d.setDate(today.getDate() - i); out.push(d);
    }
    return out;
  }
  // Petit identifiant unique pour chaque habitude (pour pouvoir la retrouver/supprimer).
  function uid() { return 'h' + Date.now() + Math.floor(Math.random() * 1000); }

  /* ---------------------------------------------------------------------------
     5. L'AFFICHAGE — reconstruire toute la liste à partir du tableau "habits".
        On efface tout puis on recrée : simple à suivre, et toujours juste.
     --------------------------------------------------------------------------- */
  function render() {
    listEl.innerHTML = '';

    // Cas particulier : aucune habitude -> un message d'invitation.
    if (!habits.length) {
      var p = document.createElement('p'); p.className = 'empty'; p.textContent = UI[lang].empty;
      listEl.appendChild(p); return;
    }

    var days = lastDays(7), todayStr = ymd(new Date());

    habits.forEach(function (h) {
      var card = document.createElement('div'); card.className = 'habit';

      // --- En-tête : le nom, le compteur de la semaine, et le bouton supprimer ---
      var head = document.createElement('div'); head.className = 'habit-head';
      var name = document.createElement('span'); name.className = 'habit-name';
      name.textContent = h.name;   // textContent : le nom est SAISI par l'utilisateur, donc jamais interprété comme du HTML
      var right = document.createElement('div'); right.style.display = 'flex'; right.style.alignItems = 'center'; right.style.gap = '8px';

      // Compteur "X/7" : combien des 7 jours affichés sont cochés.
      var streak = document.createElement('span'); streak.className = 'habit-streak';
      var weekCount = days.reduce(function (acc, d) { return acc + (h.days[ymd(d)] ? 1 : 0); }, 0);
      streak.textContent = weekCount === 7 ? UI[lang].perfect : UI[lang].weekDone(weekCount);

      var del = document.createElement('button'); del.className = 'habit-del'; del.type = 'button';
      del.textContent = '✕'; del.setAttribute('aria-label', UI[lang].del + h.name);
      del.addEventListener('click', function () {
        habits = habits.filter(function (x) { return x.id !== h.id; });   // on retire cette habitude
        save(habits); render();
      });
      right.appendChild(streak); right.appendChild(del);
      head.appendChild(name); head.appendChild(right);

      // --- Les 7 cases de la semaine ---
      var daysEl = document.createElement('div'); daysEl.className = 'days';
      days.forEach(function (d) {
        var key = ymd(d);
        var col = document.createElement('div'); col.className = 'day';
        var lab = document.createElement('span'); lab.className = 'day-label';
        lab.textContent = UI[lang].days[(d.getDay() + 6) % 7];   // getDay(): 0=dimanche ; ce calcul met lundi en tête

        // Une vraie <button> : on peut l'atteindre au clavier et l'activer avec Entrée/Espace.
        var cell = document.createElement('button');
        cell.type = 'button'; cell.className = 'cell' + (key === todayStr ? ' today' : '');
        var done = !!h.days[key];
        cell.setAttribute('aria-pressed', done ? 'true' : 'false');   // état coché/décoché annoncé au lecteur d'écran
        cell.setAttribute('aria-label', h.name + ' — ' + key);
        cell.addEventListener('click', function () {
          if (h.days[key]) delete h.days[key]; else h.days[key] = true;   // on bascule l'état du jour
          save(habits); render();
        });
        col.appendChild(lab); col.appendChild(cell); daysEl.appendChild(col);
      });

      card.appendChild(head); card.appendChild(daysEl); listEl.appendChild(card);
    });
  }

  /* ---------------------------------------------------------------------------
     6. LES ACTIONS — ajouter une habitude, changer de langue.
     --------------------------------------------------------------------------- */
  form.addEventListener('submit', function (e) {
    e.preventDefault();                 // empêche le rechargement de page par défaut du formulaire
    var v = input.value.trim();
    if (!v) return;                     // on ignore un champ vide
    habits.push({ id: uid(), name: v, days: {} });
    save(habits); input.value = ''; statusEl.textContent = UI[lang].added; render();
  });

  function setLang(next) {
    lang = next;
    try { localStorage.setItem('habits-lang', lang); } catch (e) {}
    document.documentElement.lang = lang;
    var t = UI[lang];
    document.querySelectorAll('[data-i18n]').forEach(function (el) {
      var k = el.getAttribute('data-i18n'); if (!t[k]) return;
      if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
    });
    var phEl = document.querySelector('[data-i18n-ph]'); if (phEl) phEl.placeholder = t.ph;   // le placeholder se traduit à part
    document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
      b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
    });
    statusEl.textContent = ''; render();
  }

  /* ---------------------------------------------------------------------------
     7. LE DÉMARRAGE.
     --------------------------------------------------------------------------- */
  document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
    b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
  });

  setLang(lang);   // applique la langue et déclenche le premier affichage
})();
</script>
</body>
</html>

Your turn

  • Show the last 30 days as a grid ("contributions" style).
  • A confirmation before deleting a habit.
  • Export / import data as JSON.

On every addition: is the loaded data checked? is the displayed text escaped? is the action accessible?

Next project
Mini dashboard from an API →
Next step

Your habit tracker keeps your progress in memory and shows your streaks. Next up, you go fetch live data from the web: a mini weather dashboard that queries a real API and shows the conditions in real time.

Lesson 4: Mini API dashboard →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement