Gemini Structured Output With Zod in Next.js
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
To build an offline habit tracker with JavaScript and localStorage, keep all habits in one array of objects, give each habit a list of the days it was done as "YYYY-MM-DD" strings in the user's local time, save the array with JSON.stringify() after every change, and load it with JSON.parse() on startup. Streaks are then calculated from those dates instead of being stored as a counter. In this tutorial I build a small working tracker with plain HTML and JavaScript: add habits, tick today, see the current and best streak, a 7-day row, and a JSON backup.
This is the stripped-down version of an idea from my bigger project, Ritual, a habit tracker I built with Next.js, which also keeps everything in the browser. Here there's no framework, no build step and no server. I tested the logic in Node with a fake localStorage, and the page itself in headless Chrome.
My first attempt stored streak: 5 on each habit and added 1 when you ticked it. It broke as soon as I unticked a day by mistake or skipped a day: the number no longer matched reality. Storing the actual dates fixes that. Everything else, like streaks and the weekly row, can be calculated from them at any time:
[
{ "id": "muqwtga4nrve", "name": "Read 10 pages", "done": ["2026-09-30", "2026-10-01", "2026-10-02"] }
]
I keep the data and streak code in habits.js, separate from anything that touches the page. That makes it easy to test:
// habits.js: data and streak logic, no DOM code here
const STORAGE_KEY = "habit-tracker-v1";
// "2026-10-02" in the visitor's LOCAL time zone
function dateKey(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
// Turn "2026-10-02" back into a local date (not UTC midnight)
function parseKey(key) {
const [y, m, d] = key.split("-").map(Number);
return new Date(y, m - 1, d);
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function loadHabits() {
try {
const data = JSON.parse(localStorage.getItem(STORAGE_KEY));
return Array.isArray(data) ? data : [];
} catch {
return []; // broken JSON or storage blocked
}
}
function saveHabits(habits) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
return true;
} catch {
return false; // storage full or disabled (some private modes)
}
}
function toggleDay(habit, key = dateKey()) {
habit.done = habit.done.includes(key)
? habit.done.filter((d) => d !== key)
: [...habit.done, key];
}
// Days in a row up to today. If today isn't ticked yet, count from
// yesterday, so the streak doesn't drop to 0 every morning.
function currentStreak(done, today = new Date()) {
const days = new Set(done);
let day = days.has(dateKey(today)) ? today : addDays(today, -1);
let streak = 0;
while (days.has(dateKey(day))) {
streak++;
day = addDays(day, -1);
}
return streak;
}
function bestStreak(done) {
const keys = [...new Set(done)].sort(); // YYYY-MM-DD sorts by date
let best = 0;
let run = 0;
keys.forEach((key, i) => {
const follows = i > 0 && dateKey(addDays(parseKey(keys[i - 1]), 1)) === key;
run = follows ? run + 1 : 1;
best = Math.max(best, run);
});
return best;
}
Two date bugs are hiding in most habit tracker tutorials, and both are handled here:
toISOString() gives the UTC date, not the local one. Bangladesh is UTC+6, so if you tick a habit at 1:30 AM, new Date().toISOString().slice(0, 10) returns yesterday's date. dateKey() builds the string from getFullYear(), getMonth() and getDate(), which use the device's time zone.new Date("2026-10-02") is read as UTC midnight. In a time zone behind UTC, like New York, that's still October 1st locally. parseKey() uses new Date(year, month - 1, day), which always means local midnight.Also notice that loadHabits() and saveHabits() both use try/catch. JSON.parse() throws on broken data, and setItem() can throw when storage is full or blocked. Without the catch, one bad value would stop the whole app from loading.
The streak rule is the one most habit apps use: if today isn't ticked yet, start counting from yesterday. Otherwise everyone's streak would show 0 every morning until they tick the habit.
localStorage doesn't exist in Node, so the test creates a fake one from a Map and runs habits.js inside it with the vm module:
// Runs habits.js in Node with a fake localStorage
import { readFileSync } from "node:fs";
import vm from "node:vm";
const store = new Map();
const localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
};
const ctx = vm.createContext({ localStorage });
vm.runInContext(readFileSync(new URL("./habits.js", import.meta.url), "utf8") +
";this.api={dateKey,parseKey,addDays,loadHabits,saveHabits,toggleDay,currentStreak,bestStreak,STORAGE_KEY};", ctx);
const h = ctx.api;
// 1. Local date key vs toISOString at 1:30 AM in Dhaka (UTC+6)
process.env.TZ = "Asia/Dhaka";
const early = new Date(2026, 9, 2, 1, 30);
console.log("dateKey:", h.dateKey(early), "| toISOString:", early.toISOString().slice(0, 10));
process.env.TZ = "America/New_York"; // a time zone behind UTC
console.log("parseKey:", h.parseKey("2026-10-02").getDate(), "| new Date():", new Date("2026-10-02").getDate());
process.env.TZ = "Asia/Dhaka";
// 2. Streaks
const today = new Date(2026, 9, 2);
const done = ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01"];
console.log("current (today not ticked):", h.currentStreak(done, today));
console.log("current (ticked):", h.currentStreak([...done, "2026-10-02"], today));
console.log("current (missed yesterday):", h.currentStreak(["2026-09-29", "2026-09-30"], today));
console.log("best across month end:", h.bestStreak(["2026-08-30", "2026-08-31", "2026-09-01", "2026-09-05"]));
// 3. Toggle, save, load
const habit = { id: "1", name: "Read 10 pages", done: [] };
h.toggleDay(habit, "2026-10-02");
h.toggleDay(habit, "2026-10-02");
h.toggleDay(habit, "2026-10-02");
console.log("done after 3 toggles:", JSON.stringify(habit.done));
console.log("saved:", h.saveHabits([habit]), "| loaded:", h.loadHabits()[0].name);
store.set(h.STORAGE_KEY, "{not json");
console.log("broken JSON loads as:", JSON.stringify(h.loadHabits()));
// Output:
// dateKey: 2026-10-02 | toISOString: 2026-10-01
// parseKey: 2 | new Date(): 1
// current (today not ticked): 4
// current (ticked): 5
// current (missed yesterday): 0
// best across month end: 3
// done after 3 toggles: ["2026-10-02"]
// saved: true | loaded: Read 10 pages
// broken JSON loads as: []
The first two lines show both date bugs for real: at 1:30 AM in Dhaka, toISOString() says October 1st, and in New York new Date("2026-10-02") lands on the 1st. The streak results match the rules, including a best streak that crosses the end of August. Ticking the same day three times leaves it ticked once, and broken JSON loads as an empty list instead of crashing.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Habit tracker</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 34rem; margin: 2rem auto; padding: 0 1rem; }
li { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: .75rem 0; }
.week span { display: inline-block; width: 14px; height: 14px; border-radius: 3px; background: #ddd; }
.week span.on { background: #2e9d5b; }
</style>
</head>
<body>
<h1>My habits</h1>
<form id="add-form">
<label for="habit-name">New habit</label>
<input id="habit-name" type="text" maxlength="60" required>
<button type="submit">Add</button>
</form>
<ul id="habit-list"></ul>
<button id="export" type="button">Export backup (JSON)</button>
<script src="habits.js"></script>
<script src="app.js"></script>
</body>
</html>
The two scripts load at the end of the body, in order, as classic scripts. That way the page also works when you open index.html straight from your computer, where ES module imports would be blocked.
// app.js: draws the list and handles clicks
let habits = loadHabits();
const list = document.getElementById("habit-list");
function update() {
saveHabits(habits);
render();
}
function render() {
const today = new Date();
const todayKey = dateKey(today);
list.replaceChildren(); // clear the old list
for (const habit of habits) {
const li = document.createElement("li");
const label = document.createElement("label");
const box = document.createElement("input");
box.type = "checkbox";
box.checked = habit.done.includes(todayKey);
box.addEventListener("change", () => {
toggleDay(habit, todayKey);
update();
});
label.append(box, " " + habit.name); // text, never innerHTML
const stats = document.createElement("span");
const streak = currentStreak(habit.done, today);
stats.textContent = `🔥 ${streak} day${streak === 1 ? "" : "s"} · best ${bestStreak(habit.done)}`;
const week = document.createElement("span");
week.className = "week";
for (let i = 6; i >= 0; i--) {
const key = dateKey(addDays(today, -i));
const dot = document.createElement("span");
dot.title = key;
if (habit.done.includes(key)) dot.className = "on";
week.append(dot);
}
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "Delete";
remove.addEventListener("click", () => {
if (!confirm(`Delete "${habit.name}" and its history?`)) return;
habits = habits.filter((h) => h.id !== habit.id);
update();
});
li.append(label, stats, week, remove);
list.append(li);
}
}
document.getElementById("add-form").addEventListener("submit", (event) => {
event.preventDefault();
const input = document.getElementById("habit-name");
const name = input.value.trim();
if (name === "") return;
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
habits.push({ id, name, done: [] });
input.value = "";
update();
});
document.getElementById("export").addEventListener("click", () => {
const blob = new Blob([JSON.stringify(habits, null, 2)], { type: "application/json" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `habits-${dateKey()}.json`;
link.click();
URL.revokeObjectURL(link.href);
});
// Another tab changed the data: reload it
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY) {
habits = loadHabits();
render();
}
});
render();
How it works:
update() function saves and redraws. Every change goes through it, so the screen and the stored data can't drift apart.innerHTML. Habit names go into the page with label.append() and textContent. When I added a habit called <img src=x onerror=alert(1)> in my test, it showed up as text, and no image element was created.filter() to keep every habit except the one clicked. If filter() is new to you, my post on JavaScript map, filter and reduce explains it with examples.storage event fires in other open tabs when this one saves, so two tabs don't overwrite each other's changes.Each habit row is a flex container with flex-wrap: wrap, so on a phone the streak and the 7-day row move to a second line instead of squeezing. If you want to turn the 7-day row into a full monthly heatmap, CSS Grid is the better tool. I explain when to pick each in CSS Flexbox vs Grid.
In my headless Chrome test, adding two habits, ticking one and reloading gave the expected result: two rows, "🔥 1 day · best 1", one green dot, and the tick still there after the reload.
localStorage is perfect for a personal tracker, but know its limits before you trust it with months of data:
Convert it to a string with JSON.stringify() before setItem(), and back with JSON.parse() after getItem(). localStorage only stores strings, so saving the array directly gives you "[object Object]".
Yes. Once the three files are on your device, it needs no network at all. To make the hosted version installable and offline-first, you would add a web app manifest and a service worker that caches those files.
Add an <input type="file">, read the file with await file.text(), parse it, check that it's an array, then assign it to habits and call update().
A localStorage habit tracker comes down to a few rules: store the days, not a counter; build date keys from local time; wrap JSON.parse() and setItem() in try/catch; write names with textContent; and give people an export button. MDN's guide to using the Web Storage API covers the rest of the API.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
Step by step: a Gemini key from Google AI Studio in .env.local, a server-only helper, a Next.js route handler, Vercel environment variables and a leak test.
A simple PHP math captcha in two functions: store the answer in the session, check it once with ctype_digit and ===, then add a honeypot and time check.