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.
- Persister des données dans
localStorageen sérialisant avecJSON.stringifyet en désérialisant avecJSON.parse, parce que localStorage ne stocke que du texte. - Protéger l'affichage contre une saisie hostile en construisant les éléments avec
createElementettextContentplutôt qu'innerHTML. - Calculer la date d'aujourd'hui en heure locale pour éviter qu'une case bascule de jour avant minuit selon le fuseau horaire.
- tu coches des habitudes, tu recharges la page : tes coches survivent ;
- tu saisis
<script>alert(1)</script>comme nom d'habitude et ça s'affiche en texte, sans s'exécuter ; - la console ne signale aucun
NaNni aucune erreur de date au changement de jour.
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('');
}
Relis cette seule ligne sans descendre plus bas : localStorage.setItem('habits', habits). À ton avis, que se passe-t-il à la 2e visite, quand on recharge la page ?
Vérifier ma prédiction
localStorage ne stocke que du texte. Passer le tableau habits sans JSON.stringify le convertit via toString(), ce qui donne la chaîne "[object Object]" en guise de données. Au rechargement, JSON.parse("[object Object]") lève une exception et les habitudes sont perdues. Et si tu oublies aussi de vérifier le premier chargement (JSON.parse(null) quand rien n'a encore été sauvegardé), l'app plante avant même d'afficher quoi que ce soit. Rien ne te prévient : pas d'erreur visible le premier jour, seulement tout qui disparaît le second.
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 null → JSON.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
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
La meilleure façon de consolider tout ça, c'est de continuer à faire évoluer ce projet, en gardant les mêmes réflexes à chaque nouvelle fonctionnalité. Quelques pistes ouvertes pour t'approprier le code :
- Affiche les 30 derniers jours en grille (style « contributions GitHub »).
- Une confirmation avant de supprimer une habitude (pour ne pas perdre un historique par erreur).
- Un export/import des données en JSON, pour sauvegarder ses coches ailleurs.
À chaque ajout : la donnée chargée est-elle vérifiée ? le texte affiché est-il échappé ? l'action est-elle accessible ?
Ajoute un bouton « Tout réinitialiser » qui efface toutes les habitudes et leurs coches. Tente-le seul d'abord : c'est en produisant sans modèle qu'on sort vraiment du tutoriel. Tu ne déplies le corrigé qu'après avoir essayé.
Tu as réussi si :
- un clic ouvre une confirmation (
confirm()) avant d'effacer, pour éviter la perte par erreur ; - si l'utilisateur confirme,
localStorageest bien vidé et le tableau en mémoire aussi ; - la liste se réaffiche immédiatement vide, sans rechargement de page.
Voir une solution possible
// On ajoute le bouton une seule fois dans le HTML ou on le crée ici
var resetBtn = document.createElement('button');
resetBtn.type = 'button';
resetBtn.className = 'btn';
resetBtn.textContent = 'Tout réinitialiser';
resetBtn.addEventListener('click', function () {
// confirm() : boîte native du navigateur — simple, pas de JS supplémentaire
if (!confirm('Effacer toutes les habitudes et leurs coches ?')) return;
// On vide le tableau EN MÉMOIRE (pas seulement le stockage)
habits = [];
// On écrit le tableau vide dans localStorage (écriture défensive dans try/catch)
try {
localStorage.setItem('habits-data', JSON.stringify(habits));
} catch (e) {
// Écriture impossible (quota dépassé, mode privé bloqué) : on continue quand même
}
// On rafraîchit l'affichage — l'utilisateur voit la liste vide immédiatement
render();
});
// On insère le bouton après la liste
listEl.after(resetBtn);
Le réflexe clé : localStorage.setItem peut échouer (quota dépassé, navigateur en mode privé avec stockage bloqué). Le try/catch ici n'est pas du tout superflu : sans lui, une exception muette laisserait le stockage intact et la liste reviendrait au prochain chargement. On écrit toujours dans un try/catch quand on touche au stockage.
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.
- Persist data with
localStorageby serializing withJSON.stringifyand deserializing withJSON.parse— because localStorage only stores text. - Guard your UI against hostile input by building elements with
createElementandtextContentinstead ofinnerHTML. - Compute today's date in local time to prevent a cell from flipping to the next day before midnight depending on the user's timezone.
- you tick habits, reload the page, and your ticks are still there;
- you type
<script>alert(1)</script>as a habit name and it displays as plain text, without running; - the console shows no
NaNand no date error at day rollover.
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('');
}
Read just this one line without scrolling down: localStorage.setItem('habits', habits). What do you think happens on the second visit, when you reload?
Check my prediction
localStorage only stores text. Passing the habits array without JSON.stringify converts it via toString(), which saves the string "[object Object]" as data. On reload, JSON.parse("[object Object]") throws an exception and the habits are gone. And if you also forgot to guard the first load (JSON.parse(null) when nothing has been saved yet), the app crashes before rendering anything. Nothing warns you — no visible error on day one, just everything disappearing on day two.
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 null → JSON.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
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
The best way to lock in everything here is to keep evolving this project, applying the same reflexes every time you add something. A few open ideas to make the code yours:
- Show the last 30 days as a grid ("GitHub contributions" style).
- A confirmation before deleting a habit — so you don't accidentally wipe a history.
- An export/import of the data as JSON, to back up your ticks elsewhere.
On every addition: is the loaded data checked? is the displayed text escaped? is the action accessible?
Add a "Reset everything" button that wipes all habits and their ticks. Try it on your own first — producing without a model is how you truly leave the tutorial behind. Only unfold the solution after you've tried.
You've succeeded if:
- a click opens a confirmation (
confirm()) before erasing, to prevent accidental loss; - if the user confirms,
localStorageis actually cleared and the in-memory array is too; - the list re-renders as empty immediately, without a page reload.
See one possible solution
// Create the button (or add it once in the HTML)
var resetBtn = document.createElement('button');
resetBtn.type = 'button';
resetBtn.className = 'btn';
resetBtn.textContent = 'Reset everything';
resetBtn.addEventListener('click', function () {
// confirm(): native browser dialog — simple, no extra JS
if (!confirm('Erase all habits and their ticks?')) return;
// Clear the in-memory array first (not just storage)
habits = [];
// Write the empty array to localStorage — defensive try/catch
try {
localStorage.setItem('habits-data', JSON.stringify(habits));
} catch (e) {
// Write failed (quota exceeded, private mode with storage blocked):
// we carry on anyway — the in-memory array is already empty
}
// Re-render — the user sees the empty list immediately
render();
});
// Insert the button after the list
listEl.after(resetBtn);
The key reflex: localStorage.setItem can fail — quota exceeded, or a browser in private mode with storage blocked. The try/catch isn't just ceremony here: without it, a silent exception would leave storage intact and everything would come back on the next load. Always wrap storage writes in try/catch.
Pour sauvegarder les habitudes, l'IA te propose ce code. Ton rôle de relecteur : l'accepter tel quel ou le rejeter, et dire pourquoi.
function save() {
localStorage.setItem('habits-data', habits);
}
localStorage ne stocke que du texte : passer le tableau habits directement le convertit en chaîne via toString(), ce qui donne "[object Object]". Au rechargement, JSON.parse échoue (ou ton garde-fou renvoie un tableau vide) et toutes les coches sont perdues. Il manque le JSON.stringify de l'intro : localStorage.setItem('habits-data', JSON.stringify(habits)). Le couple stringify/parse doit toujours être symétrique.Sans remonter dans la leçon : comment range-t-on une liste d'habitudes dans localStorage (qui ne stocke que du texte), et pourquoi faut-il lire ce stockage dans un try/catch ?
JSON.stringify avant de l'écrire, et on le reconstruit avec JSON.parse à la lecture. La lecture passe par un try/catch parce que le stockage n'est pas fiable : au premier lancement getItem renvoie null (donc JSON.parse(null) casse) et une donnée corrompue fait lever une exception à JSON.parse. On retombe alors sur un tableau vide, et on vérifie Array.isArray avant de l'utiliser : on ne fait jamais confiance au contenu du stockage.Ton suivi d'habitudes garde ta progression en mémoire et affiche tes séries. Au projet suivant, tu vas chercher des données vivantes sur le web : un mini-dashboard météo qui interroge une vraie API et affiche le temps en direct.
Leçon 4 : Mini-dashboard API →