MakeFirstStep· Frontend + ИИ

Урок 12 · 🏁 Мини-проект №1

Мини-проект: трекер задач или привычек

Первый настоящий проект для портфолио: приложение на чистом JavaScript, которое помнит ваши данные даже после перезагрузки страницы. Такие трекеры стоят за Todoist, Notion и десятками приложений на телефоне, а сегодня вы соберёте свой, защитите его перед группой и зальёте на GitHub.

  • Выбрать вариант проекта и разбить его на 10–15 подзадач с оценкой времени
  • Собрать приложение вокруг одного объекта состояния и функции render
  • Подключить форму, делегирование событий, фильтры и сохранение в localStorage
  • Провести code review в паре по чек-листу и исправить замечания
  • Защитить проект за 5 минут: показать, объяснить код, ответить на вопросы
  • Залить проект на GitHub и оформить его так, чтобы не стыдно было показать
Как работать с этой страницей

Сегодня урок-проект. Верхняя часть страницы — задание, критерии оценки и план. В середине — разминки: короткие задачи, из которых складывается трекер (состояние, отрисовка, события, фильтры, сохранение). Каждая проверяется автоматически, как на прошлых уроках. Дальше — стартовый шаблон в песочнице, чек-лист для проверки в парах, план защиты и заливка на GitHub. Проект вы собираете у себя в VS Code, а здесь тренируетесь и сверяетесь.

01 Задание: выберите вариант

Проект собирается из того, что вы уже делали на уроках 8–11: список на DOM, события, форма, состояние в одном объекте и сохранение. Сегодня мы доводим всё до рабочего приложения, проверяем друг друга и защищаем. Выберите один из двух вариантов — оценивать будем по одним и тем же критериям.

Вариант А. Трекер задач

Список дел: пишете задачу, добавляете, отмечаете выполненной, удаляете. Есть фильтры «Все / Активные / Готовые» и счётчик «Осталось: 3 из 5».

Сущность: { id, title, done }

Вариант Б. Трекер привычек

Список привычек («Читать 20 минут», «Тренировка»). Каждый день можно отметить «сделано», приложение показывает серию дней подряд и сколько привычек выполнено сегодня.

Сущность: { id, name, days: ["2026-10-14", …] }

Варианты по сложности почти равны: у привычек сложнее логика (даты и серия), у задач — больше режимов отображения (фильтры). Если вы только начали уверенно писать код — берите задачи. Хотите вызов — привычки. Свою идею можно предложить, но только если она по объёму такая же и вы согласовали её с преподавателем до начала работы.

Аналогия из жизни

Представьте тетрадь, в которую мама записывает, что купить на базаре. Вычеркнула купленное — запись осталась, но выглядит иначе. Закрыли тетрадь, открыли завтра — записи на месте. Ваш трекер — такая же тетрадь: состояние — это записи, render — как они выглядят на странице, localStorage — сама тетрадь, которая не теряется при закрытии.

Что вы показываете в конце

  1. Работающее приложение — открывается в браузере без ошибок в консоли.
  2. Репозиторий на GitHub с кодом и README (что это, как запустить, скриншот).
  3. Защита на 5 минут — демонстрация, объяснение кода и ответы на вопросы.
Правила курса действуют и здесь

ИИ можно использовать (мы даже учимся его применять ниже), но по правилам курса: сначала пробуете сами 15 минут, не вставляете код, который не можете объяснить построчно, и на защите преподаватель вправе попросить объяснить любую строку. Не смогли — баллы за эту часть снимаются.

02 Требования: MVP и бонусы

MVP (minimum viable product) — минимальная версия, которая уже полезна и работает от начала до конца. Сначала делаем её полностью, и только потом тратим время на украшения. Проект без MVP не получит высокой оценки, сколько бы бонусов в нём ни было.

MVP — обязательно для обоих вариантов

№Вариант А — задачиВариант Б — привычки
1Форма добавления задачи (поле + кнопка, Enter тоже работает)Форма добавления привычки (поле + кнопка)
2Валидация: пустая строка и одни пробелы не добавляются, пользователь видит сообщениеТо же + нельзя добавить привычку с уже существующим названием
3Отметка задачи выполненной (и обратно) кликомОтметка «сегодня сделано» (и снятие отметки)
4Удаление задачиУдаление привычки
5Фильтры «Все / Активные / Готовые»Фильтр «Все / Не сделано сегодня / Сделано сегодня»
6Счётчик «Осталось: N из M» и сообщение, когда список пустСерия дней подряд у каждой привычки и счётчик «Сегодня: N из M»
7Все данные сохраняются в localStorage и восстанавливаются после перезагрузки (F5). Сломанные или пустые данные не роняют приложение.
8Код устроен по схеме: состояние → функции над состоянием → render → обработчики событий. Без глобальных переменных-«свалки», без копипасты.

Бонусы — после MVP

  • Редактирование: двойной клик по тексту превращает его в поле ввода (Enter — сохранить, Esc — отменить).
  • Кнопка «Очистить выполненные» с подтверждением.
  • Поиск по списку в реальном времени (событие input).
  • Приоритеты или категории с цветными метками.
  • Тёмная тема с сохранением выбора.
  • Сортировка: по алфавиту, по дате добавления.
  • Для привычек: неделя в виде 7 точек с отметками по дням и «лучшая серия».
Совет

Выбирайте бонусы, которые вы сможете объяснить на защите. Один простой бонус, в котором вы понимаете каждую строку, ценнее трёх «магических» кусков кода из чата с ИИ.

03 Как оценивается проект (0–100)

Мини-проект оценивается по шкале 0–100, как и записано в правилах курса. Оценка складывается из семи критериев:

КритерийБаллыМаксимум ставим, если…Частично, если…
Функциональность MVP25работают все 6 пунктов MVP (добавить, проверить, отметить, удалить, фильтр, счётчик): по ~4 балла за пунктчасть пунктов работает с багами или отсутствует
DOM и отрисовка15интерфейс строится из состояния функцией render; нет дублей после повторного вызова; используется textContent вместо innerHTML для текста пользователячасть DOM правится «руками» в разных местах
События и формы15есть preventDefault, валидация с понятным сообщением, делегирование событий на списке, Enter и кнопка работают одинаковона каждый элемент списка отдельный обработчик, валидация формальная
Состояние и localStorage15одно состояние-объект, сохранение через JSON, try/catch при чтении, данные переживают F5сохраняется, но сломанные данные роняют приложение
Структура кода10короткие функции с понятными именами, нет дублирования, нет мёртвого кода и лишних console.log, константы вместо «магических» строкесть одна «функция-монстр» или копипаста
Интерфейс5аккуратно выглядит, работает на телефоне (320–400 px), есть состояния «пусто», :hover, :focusработает, но выглядит как черновик
Защита15уложились в 5 минут, показали сценарий, объяснили ключевые куски кода и ответили на вопросыобъясняете общими словами, поплыли на «объясни эту строку»
ИТОГО100

Бонусы добавляют до +5 баллов, но итог не может быть больше 100. То есть бонусы могут компенсировать мелкие потери в основных критериях.

Что снимает баллы

  • Не можете объяснить свой код — теряете баллы в том критерии, к которому относится непонятная часть (правило курса №2 и №3).
  • Не сдано в срок — до −10 баллов, по договорённости с преподавателем.
  • Код целиком скопирован у другого студента или из чата без понимания — 0 за проект. Помогать друг другу можно и нужно, но каждый пишет и понимает своё.

Как это влияет на итоговую оценку курса

Компонент курсаВесКак считается
Посещаемость15%доля посещённых уроков
Домашние задания30%средний балл за 24 ДЗ (0–10)
Мини-проекты (2 шт.)25%среднее за мини-проект 1 и 2 (0–100)
Финальный проект30%защита на уроке 21 (0–100)

Пример: за мини-проект 1 вы получили 82, за мини-проект 2 — 90. Среднее — 86, и в итог курса идёт 86 × 25% = 21.5 балла. Для сертификата нужно сдать оба мини-проекта.

БаллыОценка
90–100Отлично
75–89Хорошо
60–74Удовлетворительно
менее 60Не аттестован

04 Декомпозиция: разбиваем проект вместе с ИИ

Главная ошибка новичка перед большим проектом — сесть и начать писать «всё сразу». Через час код превращается в кашу, непонятно, что уже работает, и хочется всё бросить. Профессионалы делают иначе: сначала разбивают задачу на маленькие шаги, оценивают время каждого и идут по порядку. Это называется декомпозицией.

Аналогия из жизни

Свадебный плов на 200 человек не варят «как-нибудь». Сначала список: купить мясо и рис, нарезать морковь, развести огонь, заложить зирвак, засыпать рис, дать настояться. У каждого шага — время и ответственный. Проект — тот же плов: большая цель, из маленьких шагов.

ИИ-блок урока: большая задача — непонятно, с чего начать

Это ситуация №10 из библиотеки промптов курса. ИИ отлично умеет предлагать список шагов, но порядок, лишнее и оценку времени проверяете вы. Начните с версии без правил, а потом — с формулой курса: контекст → задача → ограничения → формат → проверка.

как сделать трекер задач
Контекст: я студент курса JavaScript, пишу учебный проект «трекер задач» на чистом JS без библиотек и фреймворков. Уже умею: DOM, события, формы, localStorage, объекты и массивы. Задача: разбей проект на 10–15 подзадач по 20–40 минут в порядке выполнения. Ограничения: в MVP только добавление, отметка, удаление, фильтры, счётчик и сохранение. Никаких библиотек. Бонусы вынеси отдельным списком в конце. Формат ответа: нумерованный список. У каждого пункта: название, что должно работать после него, оценка в минутах. Проверка: после списка назови 3 места, где новичок чаще всего ошибается в этом проекте.

Первый промпт даст общие слова и, возможно, сразу весь код, который вы не сможете объяснить. Второй даст план, с которым можно работать. Но это только черновик. Посмотрите на пример результата после вашей правки:

Пример плана для варианта А (6 часов суммарно)

№ПодзадачаВремяЧто работает после неё
1Разметка: форма, список, фильтры, счётчик30 минстраница выглядит как трекер, но «мёртвая»
2Модель данных: объект state и формат задачи20 минрешено, что хранится и в каких полях
3Функции над состоянием: addTask, toggleTask, removeTask30 минлогику можно проверить в консоли без страницы
4Функция render(): список из состояния35 минзадачи из массива появляются на странице
5Событие submit формы + валидация30 минзадачу можно добавить, пустую — нельзя
6Делегирование: клик по задаче и по «✕»30 минотметка и удаление работают
7Фильтры «Все / Активные / Готовые»25 минкнопки меняют отображаемый список
8Счётчик и сообщение «Пока нет задач»20 минпустой и заполненный список выглядят правильно
9saveState / loadState + try/catch30 минпосле F5 всё на месте
10Стили: .done, :hover, телефон30 минприложение приятно выглядит
11Ручное тестирование по чек-листу, исправление багов30 миннет ошибок в консоли
12Рефакторинг: имена, дубли, мёртвый код20 минкод читается сверху вниз
13README и скриншот15 минрепозиторий понятен
14Заливка на GitHub15 минпроект доступен по ссылке

Что вы делаете с ответом ИИ — по шагам

  1. Вычеркните лишнее. Если ИИ предложил «настроить Webpack» или «подключить React» — это не про наш проект. Мы на чистом JS.
  2. Проверьте порядок. Нельзя делать «сохранение» раньше «состояния»: сначала нужно знать, что сохранять. Каждый шаг должен опираться на готовые предыдущие.
  3. Оцените время сами. ИИ не знает, сколько времени уйдёт у вас. Умножайте его оценку на 1.5, пока нет опыта. Любой шаг длиннее 40 минут — разбейте ещё.
  4. Каждый шаг должен давать видимый результат. «Подумать над архитектурой» — не шаг. «Функция addTask проходит проверку в консоли» — шаг.
  5. Отделите MVP от бонусов. Бонусы выполняем, только когда всё из MVP работает.
  6. Запишите план в файл PLAN.md или комментарий — и отмечайте выполненное. Каждая галочка — видимый прогресс, а он держит мотивацию.

Практика: учимся считать план

План — это тоже данные. Посчитаем его средствами JavaScript.

ЗАДАЧА 4.1

Сколько займёт первый этап

лёгкая

Дан массив подзадач с оценкой времени в минутах. Посчитайте сумму и выведите строку вида Всего: 175 мин = 2.9 ч. Часы округлите до одного знака после запятой (toFixed(1)).

Можно редактировать · Ctrl + Enter
const plan = [
  { title: "Разметка страницы", min: 20 },
  { title: "Объект состояния", min: 25 },
  { title: "Функция render", min: 40 },
  { title: "Форма добавления", min: 30 },
  { title: "Отметка выполненных", min: 25 },
  { title: "Сохранение в localStorage", min: 35 },
];

// посчитайте сумму минут и выведите итоговую строку
Ожидаемый результат
Всего: 175 мин = 2.9 ч
Подсказка 1

Сумму чисел из массива объектов собирает метод reduce с начальным значением 0: на каждом шаге прибавляем item.min.

Подсказка 2

plan.reduce((sum, item) => sum + item.min, 0). Часы — это total / 60, а .toFixed(1) оставит один знак.

ЗАДАЧА 4.2

Слишком крупные шаги

средняя

Правило: любой шаг длиннее 40 минут нужно разбить. Выведите по строке для каждого такого шага в формате Разбить: Название (N мин) и в конце — сколько шагов нужно разделить: Нужно разделить: 2.

Можно редактировать · Ctrl + Enter
const plan = [
  { title: "Разметка", min: 20 },
  { title: "Фильтры и счётчики", min: 90 },
  { title: "Форма", min: 30 },
  { title: "Сохранение и загрузка", min: 60 },
  { title: "Стили", min: 25 },
];

// найдите шаги длиннее 40 минут
Ожидаемый результат
Разбить: Фильтры и счётчики (90 мин)
Разбить: Сохранение и загрузка (60 мин)
Нужно разделить: 2
Подсказка 1

Сначала отфильтруйте массив: filter оставит только шаги, где min больше 40. Результат положите в переменную.

Подсказка 2

Пройдитесь по отфильтрованному массиву через forEach и выведите строку шаблоном. Количество — это .length того же массива.

ЗАДАЧА 4.3

MVP и бонусы раздельно

лёгкая

В плане у каждого шага есть признак bonus. Посчитайте отдельно время на MVP и время на бонусы. Напишите вспомогательную функцию суммы, чтобы не повторять код.

Можно редактировать · Ctrl + Enter
const plan = [
  { title: "Разметка", min: 20, bonus: false },
  { title: "Функция render", min: 40, bonus: false },
  { title: "Форма добавления", min: 30, bonus: false },
  { title: "Сохранение", min: 50, bonus: false },
  { title: "Тёмная тема", min: 25, bonus: true },
  { title: "Поиск по задачам", min: 30, bonus: true },
];

// const sumMin = (list) => ...
// console.log(`MVP: ... мин`);
// console.log(`Бонус: ... мин`);
Ожидаемый результат
MVP: 140 мин
Бонус: 55 мин
Подсказка 1

Функция принимает массив и возвращает сумму поля min. Вызовите её дважды — для разных отфильтрованных массивов.

Подсказка 2

MVP — это plan.filter(i => !i.bonus), бонусы — plan.filter(i => i.bonus).

05 Разминка 1. Состояние — сердце трекера

План есть, переходим к коду. Начинаем с главного — состояния (урок 11). Всё, что приложение «знает», лежит в одном объекте: список задач, выбранный фильтр, следующий номер. Страница — это лишь картинка этого объекта.

Состояние трекера задач · Ctrl + Enter
const state = {
  tasks: [
    { id: 1, title: "Купить лепёшки", done: false },
    { id: 2, title: "Сделать ДЗ", done: true },
  ],
  filter: "all",   // "all" | "active" | "done"
  nextId: 3,       // какой id получит следующая задача
};

console.log(state.tasks.length);
console.log(state);

Зачем отдельное поле nextId? Если брать tasks.length + 1, то после удаления задачи номера начнут повторяться. Об этом мы ещё поговорим на ревью. А сейчас — главное правило: функции над состоянием не трогают старый объект, а возвращают новый.

Чистая функция: возвращает новое состояние · Ctrl + Enter
function addTask(state, title) {
  return {
    ...state,                                                    // копируем всё остальное
    tasks: [...state.tasks, { id: state.nextId, title, done: false }],  // новый массив
    nextId: state.nextId + 1,
  };
}

const s1 = { tasks: [], filter: "all", nextId: 1 };
const s2 = addTask(s1, "Плов");

console.log(s1.tasks.length, s2.tasks.length);  // 0 1 — старое состояние не изменилось
Аналогия из жизни

Бухгалтер в магазине на базаре не стирает старые строки в журнале, а дописывает новые. Всегда можно посмотреть, как было раньше. Функция, которая возвращает новое состояние, ведёт себя так же: старое остаётся нетронутым, поэтому ошибки легче найти.

Частая ошибка

Изменять состояние «по месту»: state.tasks.push(...) внутри функции. Работать будет, но однажды вы обнаружите, что у вас два «одинаковых» состояния, которые меняются одновременно, и не поймёте почему. Привычка «возвращаю новое» этого не допустит.

Читать подробнее: learn.javascript.ru — методы массивов · MDN — оператор расширения (spread)

Практика: закрепляем

Соберём «ядро» трекера — функции над состоянием. Их потом можно будет перенести в проект как есть.

ЗАДАЧА 5.1

Предскажите вывод

лёгкая

Сначала без запуска предскажите, что выведет код после того, как вы добавите третью задачу { id: 3, title: "Позвонить маме", done: true } в массив (push — на месте комментария). Запишите четыре ответа на бумаге, затем запустите и сверьтесь.

Можно редактировать · Ctrl + Enter
const state = {
  tasks: [{ id: 1, title: "Купить лепёшки", done: false }],
  filter: "all",
};
state.tasks.push({ id: 2, title: "Сделать ДЗ", done: true });
state.filter = "done";
// добавьте здесь третью задачу

console.log(state.tasks.length);
console.log(state.tasks[1].title);
console.log(state.filter);
console.log(state.tasks.filter((t) => t.done).length);
Ожидаемый результат
3
Сделать ДЗ
done
2
Подсказка 1

Индексы массива начинаются с нуля: tasks[1] — это вторая задача.

Подсказка 2

Третья задача добавляется так же, как вторая: state.tasks.push({ ... }). Выполненных задач после этого две (id 2 и id 3).

ЗАДАЧА 5.2

Фабрика состояния

лёгкая

Напишите функцию createState(), которая возвращает новый пустой объект состояния: tasks — пустой массив, filter — "all", nextId — 1. Каждый вызов должен давать независимый объект.

Можно редактировать · Ctrl + Enter
// function createState() { ... }

const a = createState();
const b = createState();
a.tasks.push({ id: 1, title: "тест", done: false });
console.log(JSON.stringify(a));
console.log(JSON.stringify(b));
Ожидаемый результат
{"tasks":[{"id":1,"title":"тест","done":false}],"filter":"all","nextId":1}
{"tasks":[],"filter":"all","nextId":1}
Подсказка 1

Функция должна каждый раз создавать объект заново, а не отдавать один и тот же, объявленный снаружи.

Подсказка 2

Тело функции — один return { tasks: [], ... };. Новый литерал объекта при каждом вызове — это и есть новый объект.

ЗАДАЧА 5.3

addTask без мутаций

средняя

Напишите addTask(state, title). Она убирает пробелы по краям названия (trim), при пустой строке возвращает состояние без изменений, иначе возвращает новое состояние с добавленной задачей { id, title, done: false } и увеличенным nextId. Старое состояние менять нельзя.

Можно редактировать · Ctrl + Enter
const createState = () => ({ tasks: [], filter: "all", nextId: 1 });

// function addTask(state, title) { ... }

let s = createState();
s = addTask(s, "  Купить лепёшки  ");
s = addTask(s, "   ");
s = addTask(s, "Сделать ДЗ");
console.log(s.tasks.map((t) => `${t.id}:${t.title}`).join(" | "));
console.log(s.nextId);

const before = s;
const after = addTask(s, "Позвонить маме");
console.log(before.tasks.length, after.tasks.length);
Ожидаемый результат
1:Купить лепёшки | 2:Сделать ДЗ
3
2 3
Подсказка 1

Сначала const clean = title.trim();, затем ранний return state, если clean пустая строка.

Подсказка 2

Верните { ...state, tasks: [...state.tasks, новаяЗадача], nextId: state.nextId + 1 }. Новая задача получает id: state.nextId.

ЗАДАЧА 5.4

Найдите баг: отметка не туда

средняя

Функция toggleTask должна переворачивать done только у задачи с нужным id и не менять исходное состояние. Сейчас она делает не то: «до» и «после» выглядят неправильно. Найдите две ошибки.

Можно редактировать · Ctrl + Enter
function toggleTask(state, id) {
  return {
    ...state,
    tasks: state.tasks.map((t) => {
      if (t.id = id) {
        t.done = !t.done;
      }
      return t;
    }),
  };
}

const show = (s) => s.tasks.map((t) => t.id + (t.done ? "✓" : "✗")).join(" ");
const s1 = {
  tasks: [
    { id: 1, title: "Плов", done: false },
    { id: 2, title: "Чой", done: false },
    { id: 3, title: "Самбуса", done: true },
  ],
};
const s2 = toggleTask(s1, 2);
console.log("до:", show(s1));
console.log("после:", show(s2));
Ожидаемый результат
до: 1✗ 2✗ 3✓
после: 1✗ 2✓ 3✓
Подсказка 1

Посмотрите внимательно на условие if (t.id = id): сколько там знаков равенства? Что делает один знак?

Подсказка 2

Вторая ошибка: t.done = !t.done меняет объект, который принадлежит старому состоянию. Вместо этого верните копию: { ...t, done: !t.done }, а остальные задачи — как есть.

ЗАДАЧА 5.5

Удаление, очистка, статистика

сложная

Напишите три функции, не меняя исходное состояние:

  • removeTask(state, id) — состояние без задачи с этим id;
  • clearDone(state) — состояние без выполненных задач;
  • getStats(state) — объект { total, done, left }.
Можно редактировать · Ctrl + Enter
const s = {
  tasks: [
    { id: 1, title: "Плов", done: true },
    { id: 2, title: "Чой", done: false },
    { id: 3, title: "Самбуса", done: true },
    { id: 4, title: "Курутоб", done: false },
  ],
};

// removeTask, clearDone, getStats

const ids = (st) => st.tasks.map((t) => t.id).join(" ");
console.log(ids(removeTask(s, 2)));
console.log(ids(clearDone(s)));
const st = getStats(s);
console.log(`Всего: ${st.total}, сделано: ${st.done}, осталось: ${st.left}`);
console.log(s.tasks.length);
Ожидаемый результат
1 3 4
2 4
Всего: 4, сделано: 2, осталось: 2
4
Подсказка 1

И removeTask, и clearDone — это { ...state, tasks: state.tasks.filter(...) }. Отличается только условие в filter.

Подсказка 2

В getStats посчитайте total и done (через filter и length), а left получится вычитанием.

06 Разминка 2. Функция render

Состояние само по себе на экране не видно. Нужна функция, которая берёт состояние и рисует его в DOM. Правило этой функции простое: очистить список и построить его заново из состояния. Не искать, какой элемент поменялся, не править по одному — всё с нуля. Для маленького трекера это быстро и надёжно.

Каждый блок кода на этой странице работает на собственной пустой «странице», в которой есть document. Поэтому мы можем создавать элементы через document.createElement и выводить в консоль то, что хотим проверить.

render: очистили и построили заново · Ctrl + Enter
function createTaskItem(task) {
  const li = document.createElement("li");
  li.textContent = task.title;
  if (task.done) li.classList.add("done");
  return li;
}

function render(state, list) {
  list.innerHTML = "";                                   // 1. очистили
  state.tasks.forEach((task) => list.append(createTaskItem(task)));  // 2. построили заново
}

const list = document.createElement("ul");
render({ tasks: [{ title: "Плов" }, { title: "Чой" }] }, list);
render({ tasks: [{ title: "Плов" }, { title: "Чой" }] }, list);   // второй раз: дублей нет

console.log(list.children.length);   // 2, а не 4
Аналогия из жизни

В чайхоне меню пишут мелом на доске. Когда блюдо закончилось, не подчищают одну букву за другой, а стирают доску и пишут актуальное меню заново. render — это тряпка и мел в одном флаконе: стёрли, написали по состоянию.

Две классические ошибки render

1. Забыть очистить список — после каждого вызова задачи удваиваются. 2. Вставлять текст пользователя через innerHTML — если человек введёт <b> или хуже, чужой код попадёт на страницу. Для текста используйте textContent: он показывает всё как обычные буквы.

Баг: список не очищается · Ctrl + Enter
function render(tasks, list) {
  tasks.forEach((title) => {
    const li = document.createElement("li");
    li.textContent = title;
    list.append(li);
  });
}

const list = document.createElement("ul");
render(["Плов", "Чой"], list);
render(["Плов", "Чой"], list);
console.log(list.children.length);   // 4 — дубли!

Читать подробнее: learn.javascript.ru — изменение документа · MDN — textContent

Практика: закрепляем

ЗАДАЧА 6.1

Элемент одной задачи

лёгкая

Напишите createTaskItem(task): она возвращает элемент <li> с классом task, текстом задачи и атрибутом data-id (через dataset.id). У выполненной задачи добавляется ещё класс done.

Можно редактировать · Ctrl + Enter
// function createTaskItem(task) { ... }

const li1 = createTaskItem({ id: 7, title: "Купить лепёшки", done: false });
const li2 = createTaskItem({ id: 8, title: "Сделать ДЗ", done: true });
console.log(li1.tagName, li1.className, li1.dataset.id, li1.textContent);
console.log(li2.className);
Ожидаемый результат
LI task 7 Купить лепёшки
task done
Подсказка 1

Создайте элемент document.createElement("li"), задайте li.className = "task", потом по условию li.classList.add("done").

Подсказка 2

li.dataset.id = task.id; создаёт атрибут data-id. Не забудьте return li;.

ЗАДАЧА 6.2

render без дублей

средняя

Допишите render(state, list): она очищает список и добавляет по элементу на каждую задачу из state.tasks. Второй вызов с другим состоянием должен заменить содержимое, а не добавить к нему.

Можно редактировать · Ctrl + Enter
function createTaskItem(task) {
  const li = document.createElement("li");
  li.textContent = task.title;
  return li;
}

// function render(state, list) { ... }

const list = document.createElement("ul");
const titles = () => [...list.children].map((li) => li.textContent).join(", ");

render({ tasks: [{ title: "Плов" }, { title: "Чой" }, { title: "Самбуса" }] }, list);
console.log(list.children.length, titles());
render({ tasks: [{ title: "Чой" }, { title: "Самбуса" }] }, list);
console.log(list.children.length, titles());
Ожидаемый результат
3 Плов, Чой, Самбуса
2 Чой, Самбуса
Подсказка 1

Первая строка функции — очистка: list.innerHTML = "";. Тут innerHTML безопасен: мы записываем пустую строку, а не данные пользователя.

Подсказка 2

Дальше state.tasks.forEach(task => list.append(createTaskItem(task))).

ЗАДАЧА 6.3

Пустое состояние

средняя

Если задач нет, список не должен быть пустым и «мёртвым»: покажите один элемент <li class="empty"> с текстом Пока нет задач. Добавьте первую!. Как только появляются задачи — сообщение исчезает.

Можно редактировать · Ctrl + Enter
function createTaskItem(task) {
  const li = document.createElement("li");
  li.className = "task";
  li.textContent = task.title;
  return li;
}

function render(state, list) {
  list.innerHTML = "";
  // если задач нет — покажите сообщение и выйдите
  state.tasks.forEach((task) => list.append(createTaskItem(task)));
}

const list = document.createElement("ul");
const info = () =>
  `${list.children.length} ${list.firstElementChild.className} ${list.firstElementChild.textContent}`;

render({ tasks: [] }, list);
console.log(info());
render({ tasks: [{ title: "Купить хлеб" }] }, list);
console.log(info());
render({ tasks: [] }, list);
console.log(info());
Ожидаемый результат
1 empty Пока нет задач. Добавьте первую!
1 task Купить хлеб
1 empty Пока нет задач. Добавьте первую!
Подсказка 1

После очистки проверьте state.tasks.length === 0. Если да — создайте li, задайте класс и текст, добавьте в список.

Подсказка 2

Сразу после добавления сообщения поставьте return, чтобы остальной код функции не выполнялся.

ЗАДАЧА 6.4

Найдите дыру: чужой HTML на странице

средняя

Пользователь ввёл название задачи <b>срочно!</b>. Приложение должно показать его как текст, а не превратить в жирный элемент. Сейчас в createTaskItem дыра: внутри появляется настоящий тег <b>. Исправьте одну строку.

Можно редактировать · Ctrl + Enter
function createTaskItem(task) {
  const li = document.createElement("li");
  li.innerHTML = task.title;
  return li;
}

const li = createTaskItem({ title: "<b>срочно!</b>" });
console.log(li.children.length);     // сколько вложенных элементов?
console.log(li.textContent);
console.log(li.querySelector("b"));  // должно быть null
Ожидаемый результат
0
<b>срочно!</b>
null
Подсказка 1

innerHTML разбирает строку как разметку, поэтому текст <b> превращается в настоящий тег. Нужно свойство, которое трактует всё как обычный текст.

Подсказка 2

Замените innerHTML на textContent. Остальной код менять не нужно.

07 Разминка 3. Форма, клик и делегирование

Состояние есть, отрисовка есть. Осталось оживить интерфейс: поймать отправку формы и клики по задачам. В проекте будет ровно два важных обработчика: submit на форме и click на списке.

Форма: submit + preventDefault + валидация

Обработчик submit · Ctrl + Enter
const form = document.createElement("form");
form.innerHTML = '<input name="title"><button>Добавить</button>';
const input = form.querySelector("input");

form.addEventListener("submit", (event) => {
  event.preventDefault();               // не даём браузеру перезагрузить страницу
  const title = input.value.trim();
  if (!title) {
    console.log("Введите название");    // в проекте — сообщение под полем
    return;
  }
  console.log("Добавляем:", title);
  input.value = "";
});

input.value = "   ";
form.dispatchEvent(new Event("submit", { cancelable: true }));  // имитируем нажатие
input.value = "Купить хлеб";
form.dispatchEvent(new Event("submit", { cancelable: true }));

Событие submit срабатывает и при клике на кнопку, и при нажатии Enter в поле: отдельно слушать клавишу не нужно. Здесь мы «нажимаем» кнопку через dispatchEvent, потому что в упражнениях нет живой страницы.

Список: один обработчик на всех (делегирование)

Самая частая ошибка новичков — навешивать addEventListener на каждую кнопку «✕» при создании. Но при каждом render элементы создаются заново, и обработчики теряются или множатся. Решение из урока 9 — делегирование: один обработчик на общего родителя <ul>, а внутри мы смотрим, куда именно кликнули (event.target).

Делегирование: один обработчик на списке · Ctrl + Enter
const list = document.createElement("ul");
list.innerHTML = `
  <li data-id="1">Плов <button data-action="delete">✕</button></li>
  <li data-id="2">Чой <button data-action="delete">✕</button></li>`;

list.addEventListener("click", (event) => {
  const btn = event.target.closest("button[data-action='delete']");
  if (!btn) return;                     // кликнули не по кнопке удаления
  const li = btn.closest("li");
  console.log("Удаляем задачу", li.dataset.id);
  li.remove();
});

list.querySelector("button").click();
console.log(list.children.length);      // 1
Аналогия из жизни

На курсе не ставят по охраннику у каждой аудитории — один администратор на входе видит, кто куда идёт. Так и здесь: один обработчик на списке, а event.target.closest(...) — это «посмотреть, к какой аудитории направляется человек».

Частая ошибка

Использовать event.target без closest. Если внутри кнопки есть иконка или <span>, target окажется ею, а не кнопкой, и проверка не сработает. closest("button") поднимается вверх по дереву до нужного элемента.

Читать подробнее: learn.javascript.ru — делегирование событий · learn.javascript.ru — событие submit

Практика: закрепляем

ЗАДАЧА 7.1

Счётчик нажатий

лёгкая

Навесьте на кнопку обработчик click: он увеличивает clicks и записывает в кнопку текст Нажато: N. Три вызова btn.click() уже есть — они имитируют нажатия.

Можно редактировать · Ctrl + Enter
const btn = document.createElement("button");
let clicks = 0;

// навесьте обработчик click

btn.click();
btn.click();
btn.click();
console.log(clicks);
console.log(btn.textContent);
Ожидаемый результат
3
Нажато: 3
Подсказка 1

Схема: btn.addEventListener("click", () => { ... }); — внутри увеличьте clicks.

Подсказка 2

Текст кнопки меняется через btn.textContent = "Нажато: " + clicks; — в том же обработчике.

ЗАДАЧА 7.2

Форма с валидацией

средняя

Напишите обработчик submit: он отменяет отправку (preventDefault), берёт значение поля без пробелов по краям, пустую строку игнорирует, а непустую кладёт в массив tasks и очищает поле.

Можно редактировать · Ctrl + Enter
const form = document.createElement("form");
form.innerHTML = '<input name="title"><button>Добавить</button>';
const input = form.querySelector("input");
const tasks = [];

// навесьте обработчик submit на form

function submit(value) {
  input.value = value;
  const notCancelled = form.dispatchEvent(new Event("submit", { cancelable: true }));
  console.log("отправка отменена:", !notCancelled);
}

submit("Купить лепёшки");
submit("   ");
submit("Сделать ДЗ");
console.log(tasks.join(" | "));
console.log(JSON.stringify(input.value));
Ожидаемый результат
отправка отменена: true
отправка отменена: true
отправка отменена: true
Купить лепёшки | Сделать ДЗ
""
Подсказка 1

Первая строка обработчика — event.preventDefault(): она должна выполняться при любом значении поля, иначе страница «перезагрузится».

Подсказка 2

const title = input.value.trim(); if (!title) return; — затем tasks.push(title) и input.value = "".

ЗАДАЧА 7.3

Удаление через делегирование

средняя

Навесьте один обработчик на list. Если клик пришёл по кнопке с data-action="delete", запишите id задачи в массив removed и удалите элемент <li>. Клик по самому тексту ничего не делает.

Можно редактировать · Ctrl + Enter
const list = document.createElement("ul");
list.innerHTML = `
  <li data-id="1">Плов <button data-action="delete">✕</button></li>
  <li data-id="2">Чой <button data-action="delete">✕</button></li>
  <li data-id="3">Самбуса <button data-action="delete">✕</button></li>`;
const removed = [];

// один обработчик click на list

list.querySelectorAll("button")[1].click();   // удаляем «Чой»
list.querySelector("button").click();         // удаляем «Плов»
list.querySelector("li").click();             // клик по тексту — ничего не происходит
console.log(removed.join(","));
console.log(list.children.length);
console.log(list.firstElementChild.dataset.id);
Ожидаемый результат
2,1
1
3
Подсказка 1

Внутри обработчика: const btn = event.target.closest("button[data-action='delete']"); и if (!btn) return;.

Подсказка 2

Задачу найдите так: const li = btn.closest("li"); — у неё li.dataset.id. Удаление элемента — li.remove().

ЗАДАЧА 7.4

Мини-трекер целиком

сложная

Соберите живое ядро трекера. Функции состояния и createTaskItem уже готовы. Допишите:

  1. render() — очищает список и строит его из state.tasks;
  2. обработчик submit формы — добавляет задачу (пустые игнорирует), очищает поле, вызывает render;
  3. один обработчик click на списке: клик по «✕» удаляет задачу, клик по любому другому месту задачи — переключает done. Удаление не должно ещё и переключать!
Можно редактировать · Ctrl + Enter
const root = document.createElement("div");
root.innerHTML = `
  <form><input><button>Добавить</button></form>
  <ul></ul>`;
const form = root.querySelector("form");
const input = root.querySelector("input");
const list = root.querySelector("ul");

let state = { tasks: [], nextId: 1 };

const addTask = (s, title) => ({
  tasks: [...s.tasks, { id: s.nextId, title, done: false }],
  nextId: s.nextId + 1,
});
const toggleTask = (s, id) => ({
  ...s,
  tasks: s.tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
});
const removeTask = (s, id) => ({ ...s, tasks: s.tasks.filter((t) => t.id !== id) });

function createTaskItem(task) {
  const li = document.createElement("li");
  li.dataset.id = task.id;
  if (task.done) li.classList.add("done");
  const span = document.createElement("span");
  span.textContent = task.title;
  const del = document.createElement("button");
  del.dataset.action = "delete";
  del.textContent = "✕";
  li.append(span, del);
  return li;
}

// 1) function render() { ... }
// 2) form.addEventListener("submit", ...)
// 3) list.addEventListener("click", ...)

// --- проверка: имитируем действия пользователя ---
const add = (text) => {
  input.value = text;
  form.dispatchEvent(new Event("submit", { cancelable: true }));
};
add("Плов");
add("Чой");
add("Самбуса");
console.log(list.children.length);
list.children[1].querySelector("span").click();      // отметить «Чой»
list.children[0].querySelector("button").click();    // удалить «Плов»
const summary = [...list.children]
  .map((li) => li.querySelector("span").textContent + (li.classList.contains("done") ? " ✓" : ""))
  .join(" | ");
console.log(list.children.length);
console.log(summary);
Ожидаемый результат
3
2
Чой ✓ | Самбуса
Подсказка 1

Общий шаблон любого действия: изменили state → вызвали render(). В submit: state = addTask(state, title); render();.

Подсказка 2

В обработчике клика: const li = event.target.closest("li"); if (!li) return;, id — это Number(li.dataset.id). Сначала проверьте, что клик по button[data-action='delete'] — тогда removeTask. Иначе — toggleTask.

08 Разминка 4. Фильтры, счётчики и серии

Фильтр — это не удаление задач, а другой взгляд на то же состояние. Мы храним все задачи и выбранный режим state.filter, а render показывает только подходящие. Поэтому смена фильтра никогда ничего не теряет.

Фильтр как функция от состояния · Ctrl + Enter
const tasks = [
  { title: "Плов", done: true },
  { title: "Чой", done: false },
];

function getVisible(tasks, filter) {
  if (filter === "active") return tasks.filter((t) => !t.done);
  if (filter === "done") return tasks.filter((t) => t.done);
  return tasks;   // "all" и всё остальное
}

console.log(getVisible(tasks, "active").length);
console.log(tasks.length);   // исходный массив не пострадал

Счётчики считаются из состояния, а не хранятся

Число «Осталось: 3 из 5» не нужно хранить отдельным полем и обновлять руками при каждом действии. Его всегда можно посчитать из массива. Если хранить дважды, рано или поздно значения разойдутся.

Для варианта Б: привычки и серии

У привычки нет «выполнена навсегда»: она выполняется каждый день. Поэтому вместо done храним массив дат days: ["2026-10-14", "2026-10-15"]. «Сделано сегодня» — это проверка days.includes(today), а серия — сколько дней подряд идут отметки.

Важно: не вызывайте «сегодняшнюю дату» внутри логики

Результат функции, которая сама узнаёт «сегодня», меняется каждый день, и её невозможно проверить. Поэтому функция streak(dates, today) принимает «сегодня» аргументом. В приложении вы передадите настоящую дату, а при проверке — любую выдуманную. Это общий приём: чистые функции легко тестировать.

Читать подробнее: learn.javascript.ru — методы массивов (filter, find, includes)

Практика: закрепляем

ЗАДАЧА 8.1

Три режима списка

лёгкая

Напишите getVisible(tasks, filter): для "all" возвращает все задачи, для "active" — невыполненные, для "done" — выполненные.

Можно редактировать · Ctrl + Enter
const tasks = [
  { title: "Плов", done: true },
  { title: "Чой", done: false },
  { title: "Самбуса", done: true },
  { title: "Курутоб", done: false },
];

// function getVisible(tasks, filter) { ... }

for (const f of ["all", "active", "done"]) {
  console.log(f + ":", getVisible(tasks, f).map((t) => t.title).join(", "));
}
Ожидаемый результат
all: Плов, Чой, Самбуса, Курутоб
active: Чой, Курутоб
done: Плов, Самбуса
Подсказка 1

Два условия с ранним return (для "active" и "done"), а в конце — return tasks.

Подсказка 2

Невыполненные: tasks.filter(t => !t.done), выполненные: tasks.filter(t => t.done).

ЗАДАЧА 8.2

Текст счётчика

средняя

Напишите counterText(tasks). Если задач нет — Задач пока нет. Если все выполнены — Всё сделано!. Иначе — Осталось: N из M, где N — невыполненные, M — все.

Можно редактировать · Ctrl + Enter
// function counterText(tasks) { ... }

console.log(counterText([]));
console.log(counterText([{ done: true }, { done: false }, { done: false }, { done: true }]));
console.log(counterText([{ done: true }, { done: true }]));
Ожидаемый результат
Задач пока нет
Осталось: 2 из 4
Всё сделано!
Подсказка 1

Проверяйте особые случаи раньше обычного: сначала пустой массив, потом «все выполнены», в конце — обычная строка.

Подсказка 2

Число невыполненных: tasks.filter(t => !t.done).length. Строка: `Осталось: ${left} из ${tasks.length}`.

ЗАДАЧА 8.3

Кнопки фильтров

средняя

Навесьте один обработчик click на контейнер box. Клик по кнопке с data-filter записывает значение в state.filter и переносит класс active на нажатую кнопку (у остальных он пропадает).

Можно редактировать · Ctrl + Enter
const state = { filter: "all" };
const box = document.createElement("div");
box.innerHTML = `
  <button data-filter="all" class="active">Все</button>
  <button data-filter="active">Активные</button>
  <button data-filter="done">Готовые</button>`;

// один обработчик click на box

const report = () => {
  const act = [...box.querySelectorAll(".active")].map((b) => b.textContent);
  console.log(state.filter, act.join(","));
};
report();
box.querySelector("[data-filter='done']").click();
report();
box.querySelector("[data-filter='active']").click();
report();
Ожидаемый результат
all Все
done Готовые
active Активные
Подсказка 1

Найдите кнопку: event.target.closest("button[data-filter]"), при null — выходите. Значение фильтра лежит в btn.dataset.filter.

Подсказка 2

Пройдитесь по всем кнопкам и для каждой вызовите b.classList.toggle("active", b === btn) — второй аргумент включает или выключает класс.

ЗАДАЧА 8.4

Серия дней подряд (для привычек)

сложная

Напишите streak(dates, today) — сколько дней подряд отмечена привычка. Правила: если сегодня отмечено — считаем назад от сегодня; если сегодня ещё нет, но вчера было — серия не прервалась, считаем от вчера; если нет ни сегодня, ни вчера — серия 0. Повторы дат не влияют на результат. Функция prevDay уже готова: она возвращает предыдущий день.

Можно редактировать · Ctrl + Enter
const prevDay = (str) => {
  const d = new Date(str + "T00:00:00Z");
  d.setUTCDate(d.getUTCDate() - 1);
  return d.toISOString().slice(0, 10);
};

// function streak(dates, today) { ... }

const today = "2026-10-15";
console.log(streak(["2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15"], today));
console.log(streak(["2026-10-10", "2026-10-13", "2026-10-14"], today));
console.log(streak(["2026-10-01", "2026-10-02"], today));
console.log(streak([], today));
console.log(streak(["2026-10-15", "2026-10-14", "2026-10-14", "2026-10-11"], today));
Ожидаемый результат
4
2
0
0
2
Подсказка 1

Выберите стартовый день: today, если он есть в dates, иначе prevDay(today). Дальше идите назад, пока дата есть в массиве.

Подсказка 2

Цикл while (dates.includes(day)) { count++; day = prevDay(day); }. Если стартовый день не найден, цикл сразу остановится и вернётся 0.

09 Разминка 5. Сохранение: localStorage и JSON

Последний кирпич MVP — чтобы данные не пропадали после закрытия вкладки. localStorage — это маленькое хранилище прямо в браузере: пары «ключ — значение». Три метода, которые нужны почти всегда: setItem(key, value), getItem(key) и removeItem(key).

Главное ограничение: хранятся только строки. Число станет строкой "10", а объект превратится в бесполезное "[object Object]". Поэтому состояние перед записью превращаем в JSON-строку (JSON.stringify), а после чтения — обратно в объект (JSON.parse).

Оговорка про песочницу этой страницы

Код на этой странице запускается в изолированной «песочнице» браузера — это защита, чтобы чужой код не мог добраться до ваших данных. В ней настоящий localStorage заблокирован. Поэтому в упражнениях мы используем заменитель — объект storage с тем же API (getItem, setItem, removeItem), который хранит данные в памяти. Логика кода при этом одна и та же. В своём проекте в VS Code вы замените весь блок одной строкой: const storage = localStorage;.

Проверим песочницу: доступен ли localStorage · Ctrl + Enter
try {
  localStorage.setItem("test", "1");
  console.log("localStorage работает");
} catch (error) {
  console.log("localStorage недоступен:", error.name);
}

Обычно вы увидите SecurityError. Это нормально: именно поэтому ниже мы работаем через заменитель. Скопируйте его — он нужен в каждом упражнении этого раздела:

Заменитель localStorage с тем же API · Ctrl + Enter
const storage = {
  data: {},
  getItem(key) { return key in this.data ? this.data[key] : null; },
  setItem(key, value) { this.data[key] = String(value); },   // как и настоящий: всё в строку
  removeItem(key) { delete this.data[key]; },
};

storage.setItem("score", 10);
console.log(typeof storage.getItem("score"));   // string, а не number
console.log(storage.getItem("нет такого"));     // null — ключа нет

const state = { tasks: [{ id: 1, title: "Плов", done: false }], nextId: 2 };
storage.setItem("tracker:v1", JSON.stringify(state));   // объект → строка
console.log(storage.getItem("tracker:v1"));
const back = JSON.parse(storage.getItem("tracker:v1")); // строка → объект
console.log(back.tasks[0].title);
Аналогия из жизни

Отправляя посылку из Душанбе в Худжанд, вы не везёте шкаф целиком: разбираете, упаковываете в коробки (JSON.stringify), а получатель собирает обратно (JSON.parse). Хранилище понимает только «коробки» — то есть строки.

Читаем данные безопасно

Данные в localStorage могли испортиться, их мог вручную поправить пользователь, а при первом запуске их просто нет. Если слепо вызвать JSON.parse, приложение упадёт ещё до первого кадра. Поэтому читаем так: try/catch + проверка формы + запасной вариант.

Безопасная загрузка состояния · Ctrl + Enter
const storage = { data: {}, getItem(k) { return k in this.data ? this.data[k] : null; }, setItem(k, v) { this.data[k] = String(v); } };
const KEY = "tracker:v1";

function loadState() {
  try {
    const state = JSON.parse(storage.getItem(KEY));     // null или сломанный JSON
    if (state && Array.isArray(state.tasks)) return state;  // проверяем форму
  } catch {
    // сломанный JSON — ничего страшного, начнём с чистого листа
  }
  return { tasks: [], filter: "all", nextId: 1 };
}

console.log(loadState().tasks.length);     // 0 — данных ещё нет
storage.setItem(KEY, "{oops");
console.log(loadState().tasks.length);     // 0 — JSON сломан, приложение живо
Частые ошибки

1. Забыть JSON.stringify и записать объект напрямую — получите "[object Object]". 2. Забыть, что getItem вернёт строку: "10" + 5 даёт "105". 3. Использовать общий ключ вроде "data": чужой проект на том же сайте перезапишет ваши данные. Берите ключ с префиксом: "tracker:v1" (а v1 пригодится, если когда-нибудь поменяется формат).

Читать подробнее: learn.javascript.ru — localStorage, sessionStorage · learn.javascript.ru — JSON · MDN — localStorage

Практика: закрепляем

ЗАДАЧА 9.1

Число стало строкой

лёгкая

Игровой счёт 10 записали в хранилище, прочитали и хотим прибавить 5. Ожидаем 15, а получаем что-то странное. Исправьте код.

Можно редактировать · Ctrl + Enter
const storage = {
  data: {},
  getItem(key) { return key in this.data ? this.data[key] : null; },
  setItem(key, value) { this.data[key] = String(value); },
  removeItem(key) { delete this.data[key]; },
};

storage.setItem("score", 10);
const score = storage.getItem("score");
console.log(score + 5);   // хотим 15
Ожидаемый результат
15
Подсказка 1

Запустите как есть и посмотрите на результат. Что делает +, если с одной стороны строка?

Подсказка 2

Превратите прочитанное значение в число: Number(storage.getItem("score")).

ЗАДАЧА 9.2

Сохранить и прочитать состояние

средняя

Напишите saveState(state) (записывает JSON в storage под ключом KEY) и loadState() (читает и превращает обратно в объект).

Можно редактировать · Ctrl + Enter
const storage = {
  data: {},
  getItem(key) { return key in this.data ? this.data[key] : null; },
  setItem(key, value) { this.data[key] = String(value); },
  removeItem(key) { delete this.data[key]; },
};
const KEY = "tracker:v1";

// const saveState = (state) => ...
// const loadState = () => ...

saveState({
  tasks: [{ id: 1, title: "Плов", done: false }, { id: 2, title: "Чой", done: true }],
  filter: "all",
  nextId: 3,
});
console.log(typeof storage.getItem(KEY));
const loaded = loadState();
console.log(loaded.tasks.length);
console.log(loaded.tasks[1].title, loaded.tasks[1].done);
console.log(loaded.nextId);
Ожидаемый результат
string
2
Чой true
3
Подсказка 1

Запись: storage.setItem(KEY, JSON.stringify(state)). Чтение: JSON.parse(storage.getItem(KEY)).

Подсказка 2

Обе функции — по одной строке. Проверьте себя: typeof прочитанного значения должен быть "string", а loaded.tasks — настоящий массив.

ЗАДАЧА 9.3

Сломанные данные не должны ломать приложение

средняя

Сейчас loadState падает с ошибкой, если данных нет, они сломаны или имеют неправильную форму. Перепишите её: try/catch, проверка Array.isArray(state.tasks) и запасное пустое состояние.

Можно редактировать · Ctrl + Enter
const storage = {
  data: {},
  getItem(key) { return key in this.data ? this.data[key] : null; },
  setItem(key, value) { this.data[key] = String(value); },
  removeItem(key) { delete this.data[key]; },
};
const KEY = "tracker:v1";

function loadState() {
  return JSON.parse(storage.getItem(KEY));
}

console.log("пусто:", loadState().tasks.length);
storage.setItem(KEY, "{oops, не JSON");
console.log("сломано:", loadState().tasks.length);
storage.setItem(KEY, JSON.stringify({ tasks: [{ id: 1 }, { id: 2 }], filter: "all", nextId: 3 }));
console.log("нормально:", loadState().tasks.length);
storage.setItem(KEY, JSON.stringify({ tasks: "oops" }));
console.log("странно:", loadState().tasks.length);
Ожидаемый результат
пусто: 0
сломано: 0
нормально: 2
странно: 0
Подсказка 1

Обмотайте JSON.parse в try { ... } catch { ... }. Внутри try после разбора проверьте форму и верните состояние.

Подсказка 2

Условие формы: state && Array.isArray(state.tasks) (для null и для "oops" оно ложно). Если проверка не прошла или была ошибка — return { tasks: [], filter: "all", nextId: 1 } после блока try/catch.

ЗАДАЧА 9.4

«Перезагрузка страницы»

сложная

Допишите load() и save(state) так, чтобы после «перезагрузки» (boot()) всё восстанавливалось: задачи, отметки и номер для следующей. Функция load должна выдержать и неправильные данные: нужны tasks — массив и nextId — число, иначе возвращаем fresh().

Можно редактировать · Ctrl + Enter
const storage = {
  data: {},
  getItem(key) { return key in this.data ? this.data[key] : null; },
  setItem(key, value) { this.data[key] = String(value); },
  removeItem(key) { delete this.data[key]; },
};
const KEY = "tracker:v1";
const fresh = () => ({ tasks: [], nextId: 1 });
const add = (s, title) => ({
  tasks: [...s.tasks, { id: s.nextId, title, done: false }],
  nextId: s.nextId + 1,
});
const toggle = (s, id) => ({
  ...s,
  tasks: s.tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
});

// function load() { ... }
// function save(state) { ... }

const boot = () => load();   // так приложение стартует после F5

let s = boot();
s = add(s, "Плов"); save(s);
s = add(s, "Чой"); save(s);
s = boot();                    // «перезагрузили страницу»
console.log(s.tasks.length, s.nextId);
s = add(s, "Самбуса"); save(s);
s = boot();
console.log(s.tasks.map((t) => t.id + ":" + t.title).join(" "));
s = toggle(s, 2); save(s);
s = boot();
console.log(s.tasks.filter((t) => t.done).map((t) => t.title).join());
storage.setItem(KEY, '{"tasks": "oops"}');   // испортили данные
console.log(boot().tasks.length);
Ожидаемый результат
2 3
1:Плов 2:Чой 3:Самбуса
Чой
0
Подсказка 1

save — одна строка с JSON.stringify. В load повторите приём из прошлой задачи: try/catch и проверка формы.

Подсказка 2

Проверка формы: data && Array.isArray(data.tasks) && typeof data.nextId === "number". Если она прошла — верните data, иначе (и при ошибке) — fresh().

10 Стартовый шаблон проекта

Ниже — рабочий скелет трекера задач. Он уже умеет добавлять задачи и рисовать их, а остальное — ваши «TODO». Код разбит на шесть блоков в том порядке, в каком его удобно читать и объяснять на защите: хранилище → состояние → элементы страницы → отрисовка → единая точка изменений → события.

  1. Хранилище. В песочнице — заменитель, в проекте — настоящий localStorage.
  2. Состояние и функции над ним. Чистые функции: получили состояние — вернули новое.
  3. Элементы страницы. Один раз находим всё нужное через querySelector.
  4. Отрисовка. render() строит интерфейс из state. Больше никто DOM не трогает.
  5. Единая точка изменений. commit(next): записать новое состояние, сохранить, перерисовать. Любое действие пользователя заканчивается ею.
  6. События. Форма, список (делегирование), фильтры.

Нажмите ▶ Запустить, добавьте пару задач — и вы увидите, что отметка, удаление и фильтры пока не работают. Это задача для вас.

Практика: достроим стартовый трекер

ЗАДАЧА 10.1

Допишите TODO — и трекер заработает

сложная

Найдите в script.js пять отметок TODO и реализуйте их: 1) toggleTask, 2) removeTask, 3) getVisible, 4) load и save (с защитой от сломанных данных), 5) обработчик кнопок фильтров. Внизу файла — блок самопроверки: он сам «кликает» по интерфейсу и печатает результат в консоль. Когда всё готово, там должно быть ровно то, что в блоке «Ожидаемый результат». Блок самопроверки в свой проект не копируйте.

Стартовый трекер задач · Ctrl + Enter
<main class="app">
  <h1>Трекер задач</h1>
  <form id="form">
    <input id="title" placeholder="Что нужно сделать?" autocomplete="off">
    <button>Добавить</button>
  </form>
  <div class="filters" id="filters">
    <button data-filter="all">Все</button>
    <button data-filter="active">Активные</button>
    <button data-filter="done">Готовые</button>
  </div>
  <ul id="list"></ul>
  <p id="counter"></p>
</main>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
добавление: 3
отметка: 1
удаление: 2
фильтр: 1
хранилище: 2
Подсказка 1

Запустите шаблон как есть: в консоли будет отметка: 0, удаление: 3 и так далее — это и есть список того, что нужно доделать. Решайте по одному TODO и запускайте после каждого.

Подсказка 2

toggleTask и removeTask — те же функции, что вы писали в разминке (map с копией задачи и filter). getVisible смотрит на state.filter. load: try/catch + Array.isArray. Фильтры: event.target.closest("button[data-filter]"), затем commit({ ...state, filter: btn.dataset.filter }).

Как перенести шаблон в свой проект

  1. В VS Code создайте папку проекта и три файла: index.html, style.css, script.js. Разметку из вкладки index.html положите внутрь <body>, остальные вкладки — в одноимённые файлы.
  2. В index.html подключите стили и скрипт: <link rel="stylesheet" href="style.css"> и в конце body — <script src="script.js"></script>.
  3. Замените блок «Хранилище» одной строкой: const storage = localStorage;.
  4. Удалите блок самопроверки внизу файла.
  5. Если у вас уже есть проект из уроков 9–11 — не выбрасывайте его. Сравните его структуру со шаблоном и перестройте по схеме «состояние → render → commit → события».

Вариант Б: что меняется в шаблоне для привычек

ЧастьЗадачиПривычки
Сущность{ id, title, done }{ id, name, days: [] }
Массив в состоянииtaskshabits
ОтметкаtoggleTask(state, id)toggleToday(state, id, today)
«Выполнено?»task.donehabit.days.includes(today)
Дополнительно—streak(days, today) из задачи 8.4
СчётчикОсталось: N из MСегодня: N из M
Отметка привычки на сегодня (вариант Б) · Ctrl + Enter
const toggleToday = (state, id, today) => ({
  ...state,
  habits: state.habits.map((h) => {
    if (h.id !== id) return h;
    const days = h.days.includes(today)
      ? h.days.filter((d) => d !== today)   // уже была отметка — снимаем
      : [...h.days, today];                 // отметки не было — ставим
    return { ...h, days };
  }),
});

const state = { habits: [{ id: 1, name: "Читать 20 минут", days: ["2026-10-14"] }] };
const today = "2026-10-15";   // в приложении: new Date().toISOString().slice(0, 10)

const s1 = toggleToday(state, 1, today);
console.log(s1.habits[0].days.join(", "));
const s2 = toggleToday(s1, 1, today);
console.log(s2.habits[0].days.join(", "));

Заметка: toISOString() считает дату по UTC. Для учебного проекта это допустимо, но около полуночи «сегодня» может отличаться от вашего местного дня на несколько часов. Это можно упомянуть на защите как известное ограничение.

11 Практика на уроке: 90 минут

На занятии вы доводите проект до MVP, проверяете друг друга и начинаете защиты. Время ограничено, поэтому действуем по плану.

  1. 0–10 мин · Декомпозиция. Вы выбрали вариант (А или Б), составили вместе с ИИ план из 10–15 шагов и поправили его по правилам из раздела 04. Покажите план соседу: 1 минута на вопрос «где у тебя самый рискованный шаг?». Преподаватель утверждает варианты и планы.
  2. 10–50 мин · Доработка проекта. Идёте по своему плану сверху вниз. Правило пятнадцати минут: каждые 15 минут — стоп, F5, проверка в браузере и в консоли. Застряли — сначала сами 15 минут, потом сосед, потом ИИ, потом преподаватель.
  3. 50–70 мин · Code review в парах. По 10 минут на проект. Ревьюер идёт по чек-листу из раздела 12, автор слушает и записывает. Затем меняетесь.
  4. 70–90 мин · Исправления и начало защит. Правите замечания ревью. Защиты идут по очереди (5 минут + минута на смену), порядок определяет преподаватель. Кто не успел сегодня — защищается в начале следующего занятия или по договорённости.

Контрольные точки: где вы должны быть

ВремяДолжно работать
через 20 минразметка, состояние, render: задачи из массива видны на странице
через 30 минформа добавляет, клик по задаче отмечает, «✕» удаляет
через 40 минфильтры, счётчик и пустое состояние
к 50-й минутесохранение: после F5 всё на месте. Это MVP, дальше — бонусы и красота
Если вы отстаёте

Не паникуйте и не добавляйте бонусы. Откройте стартовый шаблон из раздела 10 — он закрывает форму, render и структуру. Доделайте пять TODO, а оставшееся время потратьте на валидацию с сообщением, стили и README. Работающий простой проект лучше недоделанного сложного.

ИИ на практике — по правилам курса

Копилот включён, но: сначала 15 минут сами; просите объяснение, а не только код; всё, что вставили, вы обязаны объяснить построчно. В конце урока преподаватель вправе спросить про любую строку. Хороший способ использовать ИИ сегодня — спросить: «Объясни, почему в моём коде после перезагрузки задачи дублируются» и приложить свой код и описание ошибки, а не просить «сделай как надо».

12 Code review: проверяем друг друга

Code review — когда один разработчик читает код другого до того, как он «уйдёт в мир». Это не экзамен и не поиск виноватых: свежий взгляд видит то, что автор давно перестал замечать. В реальных командах без ревью код вообще не принимают.

Аналогия из жизни

Перед свадьбой плов пробует не только повар, но и ещё два-три человека: повар уже привык к вкусу и может не заметить, что мало соли. Ревью — та же «проба со стороны».

Как проходит ревью (10 минут на проект)

  1. 2 мин — запуск. Автор показывает приложение, ревьюер сам пробует его сломать.
  2. 5 мин — чтение кода. Ревьюер идёт по чек-листу ниже и записывает замечания.
  3. 3 мин — разговор. Три пункта «что хорошо», три — «что улучшить». Автор задаёт уточняющие вопросы и решает, что принять.

Чек-лист ревьюера

Пробуем сломать (запуск)

  • ☐ Пустая строка и одни пробелы не добавляются
  • ☐ Enter в поле работает как кнопка
  • ☐ Очень длинное название не ломает вёрстку
  • ☐ Название <b>test</b> показывается как текст
  • ☐ Удалили середину списка, добавили новую — всё работает
  • ☐ F5 — данные на месте
  • ☐ В localStorage вручную записать мусор, F5 — приложение живо
  • ☐ Нет красных ошибок в консоли
  • ☐ На ширине 360 px всё читаемо

Читаем код

  • ☐ Одно состояние-объект, а не разбросанные переменные
  • ☐ render строит интерфейс из состояния и очищает список
  • ☐ Функции над состоянием не изменяют старое состояние
  • ☐ Есть preventDefault и валидация
  • ☐ События списка — через делегирование, а не на каждый элемент
  • ☐ Текст пользователя через textContent
  • ☐ Чтение из localStorage защищено try/catch
  • ☐ Понятные имена, нет a, x, data2
  • ☐ Нет копипасты, закомментированного кода и лишних console.log
  • ☐ «Магические» строки ("done", ключ хранилища) вынесены в константы

Как писать замечания

Плохо

  • «Код плохой»
  • «Переделай всё»
  • «Ты неправильно назвал»

Хорошо

  • «В script.js:42 id считается как length + 1. После удаления номера повторятся. Может, хранить nextId?»
  • «Классно, что render короткий и читается сверху вниз»
  • «Переменная d в toggle — это что? Может, task?»

Формула замечания: где (файл и строка) → что вижу → почему это важно → предложение (лучше вопросом). Критикуем код, а не человека. Ревьюер не переписывает чужой код за автора.

ИИ как второй ревьюер — после людей, а не вместо них

Когда пара закончила, можно прогнать код через ИИ: «Проверь код по чек-листу: имена, размер функций, дублирование, ранний возврат, разделение логики и отображения. Только замечания, без переписывания». Принимайте не все советы — каждую правку вы должны уметь обосновать.

Читать подробнее: learn.javascript.ru — отладка в браузере

Практика: ревью в миниатюре

ЗАДАЧА 12.1

Замечание ревьюера: id повторяются

средняя

Ревьюер написал: «После удаления задачи из середины новая получает уже занятый номер». Проверьте и исправьте: addTask должна использовать state.nextId и увеличивать его. Ожидаем номера 1,3,4, а получаем дубль.

Можно редактировать · Ctrl + Enter
function addTask(state, title) {
  const id = state.tasks.length + 1;
  return { ...state, tasks: [...state.tasks, { id, title, done: false }] };
}

let s = { tasks: [], nextId: 1 };
s = addTask(s, "Плов");
s = addTask(s, "Чой");
s = addTask(s, "Самбуса");
s = { ...s, tasks: s.tasks.filter((t) => t.id !== 2) };   // удалили «Чой»
s = addTask(s, "Курутоб");
console.log(s.tasks.map((t) => t.id).join(","));
Ожидаемый результат
1,3,4
Подсказка 1

Проследите: после удаления в массиве две задачи — length + 1 даёт 3, а такой номер уже есть. Номер должен только расти и не зависеть от длины массива.

Подсказка 2

const id = state.nextId; и в возвращаемом объекте добавьте nextId: id + 1.

ЗАДАЧА 12.2

Замечание ревьюера: опечатка и магические строки

лёгкая

Фильтр «Активные» показывает всех. Ревьюер подозревает опечатку в строке. Исправьте её, а заодно по совету ревью вынесите строки фильтров в константу FILTER (ALL, ACTIVE, DONE) и используйте её и в функции, и в вызовах — так опечатка станет ошибкой, которую заметит редактор.

Можно редактировать · Ctrl + Enter
function getVisible(tasks, filter) {
  if (filter === "activ") return tasks.filter((t) => !t.done);
  if (filter === "done") return tasks.filter((t) => t.done);
  return tasks;
}

const tasks = [
  { title: "Плов", done: true },
  { title: "Чой", done: false },
  { title: "Самбуса", done: false },
];
console.log(getVisible(tasks, "all").length);
console.log(getVisible(tasks, "active").length);
console.log(getVisible(tasks, "done").length);
Ожидаемый результат
3
2
1
Подсказка 1

Сравните строку в условии с той, которую передают при вызове. Они должны совпадать до буквы.

Подсказка 2

const FILTER = { ALL: "all", ACTIVE: "active", DONE: "done" }; — а дальше везде FILTER.ACTIVE вместо строки.

13 Защита: 5 минут, которые решают 15 баллов

Защита — это короткий рассказ о том, что вы сделали и как это работает. Не презентация и не допрос: вам нужно показать, что проект — ваш и вы его понимаете. Именно поэтому на защиту отведено 15 баллов из 100.

Сценарий на 5 минут

ВремяЧто делаете
0:00–0:30Представление. Одной фразой: что за приложение, какой вариант, для кого. «Это трекер привычек: отмечаешь день — растёт серия».
0:30–2:00Демо по сценарию. Добавить → отметить → удалить → фильтр → F5 и показать, что данные остались. Заранее проверьте сценарий дома.
2:00–3:30Код. Покажите три места: объект state, функцию render, один обработчик событий. Объясняйте своими словами, не читайте строки вслух.
3:30–5:00Вопросы. Преподаватель и группа задают вопросы. Не знаете — скажите честно и предположите, как бы искали ответ.

Типичные вопросы и о чём на них нужно сказать

Что такое state и где он у тебя?
Один объект со всеми данными приложения: список, фильтр, следующий id. Страница рисуется из него. Покажите его в коде.
Зачем render каждый раз строит список заново?
Проще и надёжнее: не нужно искать, какой элемент изменился. Страница всегда соответствует состоянию. Для небольшого списка это быстро.
Что такое делегирование и зачем оно здесь?
Один обработчик на списке вместо обработчика на каждой кнопке. После render элементы создаются заново, а обработчик на родителе остаётся и работает для новых элементов.
Зачем нужен preventDefault в submit?
Без него браузер отправит форму и перезагрузит страницу — состояние в памяти потеряется.
Что и в каком виде лежит в localStorage?
JSON-строка с состоянием под ключом проекта. Хранилище понимает только строки, поэтому JSON.stringify при записи и JSON.parse при чтении.
Что будет, если в хранилище мусор?
try/catch вокруг JSON.parse и проверка формы: при проблеме приложение стартует с пустым состоянием и не падает.
Как у тебя считается id? Что после удаления?
Через отдельный счётчик nextId, который только растёт. length + 1 дал бы повторы.
Почему textContent, а не innerHTML?
Текст пользователя нельзя воспринимать как разметку: иначе в приложение можно подсунуть чужой HTML и скрипты.
Где ты использовал ИИ? Объясни эту строку.
Скажите честно, для чего: декомпозиция, объяснение ошибки, проверка. Любую строку, которую показал преподаватель, нужно объяснить своими словами.
Что бы ты улучшил, будь у тебя ещё день?
Назовите 1–2 конкретные вещи: редактирование, поиск, тесты. Это показывает, что вы видите ограничения проекта.

Советы по подготовке

  • Прогоните сценарий демо вслух с секундомером минимум один раз.
  • Откройте нужные файлы заранее и увеличьте шрифт в редакторе: группа должна видеть код.
  • Уберите лишнее: закомментированный код, console.log, тестовые данные в хранилище.
  • Не оправдывайтесь («я не успел»). Лучше: «этого нет, потому что… вот как я бы добавил».
  • Не знаете ответ — это нормально. Скажите «не помню, но попробую найти в документации и покажу» — это оценивается лучше, чем угадывание.

Практика: репетиция

ЗАДАЧА 13.1

Подготовьте ответы на вопросы защиты

лёгкая

Прочитайте вопросы выше и ответьте на пять из них про свой проект, своими словами, одним-двумя предложениями. Запишите ответы в объект ниже (это просто заметки — автоматической проверки здесь нет), затем отрепетируйте их вслух перед соседом по паре. Когда ответите — отметьте задачу вручную.

Можно редактировать · Ctrl + Enter
const defence = {
  state: "Мой state — объект с полями ... Он лежит в ...",
  render: "",
  delegation: "",
  storage: "",
  improve: "",
};

console.log(Object.keys(defence).length + " ответов подготовлено");
Подсказка 1

Отвечайте как объяснили бы другу, который ни разу не видел ваш код: коротко и с примером из вашего проекта.

Подсказка 2

Хороший ответ содержит конкретику: «в моём коде render лежит в 40-й строке и вызывается из commit», а не «ну он что-то рисует».

14 Заливка проекта на GitHub

Проект, который лежит только на вашем ноутбуке, не существует для работодателя. GitHub — это ваше портфолио. Базовый Git (init, commit, push) вы знаете из входных требований курса; системно мы разберём его на уроке 19, а сегодня — минимальный путь «папка → репозиторий».

  1. Создайте репозиторий. На github.com нажмите New repository. Имя — английское, через дефис: task-tracker или habit-tracker. Выберите Public. Галочки «Add a README», «.gitignore» и «license» не ставьте — репозиторий должен быть пустым.
  2. Инициализируйте Git в папке проекта и сделайте первый коммит:
    Терминал (в папке проекта)
    git init
    git add .
    git commit -m "add tracker app"
  3. Привяжите репозиторий и отправьте код. Скопируйте адрес со страницы нового репозитория (вместо ВАШ_ЛОГИН подставьте ваше имя на GitHub):
    Терминал
    git branch -M main
    git remote add origin https://github.com/ВАШ_ЛОГИН/task-tracker.git
    git push -u origin main
  4. Проверьте результат. Обновите страницу репозитория: файлы на месте, в корне виден index.html. Откройте репозиторий в приватном окне браузера или с телефона — ссылка должна открываться без входа в аккаунт.
  5. Добавьте README.md по шаблону ниже, сделайте скриншот приложения и положите его в репозиторий.
  6. Исправления по ревью заливайте отдельными коммитами — так в истории видно, что вы доработали:
    Терминал
    git add .
    git commit -m "fix duplicate ids after delete"
    git push
Как называть коммиты

Коротко, по-английски, с маленькой буквы, глаголом в повелительном наклонении: add task filters, fix empty title validation, remove dead code. Не update, не fix, не aaa. Хотя бы 5 осмысленных коммитов за проект — это хороший признак аккуратной работы.

README.md — шаблон
# Трекер задач

Приложение на чистом JavaScript: добавление, отметка и удаление задач,
фильтры, счётчик. Данные сохраняются в localStorage.

## Что умеет
- добавлять задачи (с проверкой на пустую строку)
- отмечать выполненными и удалять
- фильтры «Все / Активные / Готовые»
- помнить данные после перезагрузки

## Стек
HTML, CSS, JavaScript (без библиотек)

## Запуск
Откройте index.html в браузере.

## Скриншот
![Скриншот](screenshot.png)

## Что исправил после code review
- ...

## Что можно улучшить
- ...
Не забудьте

README пишите своими словами. Если вы вставили текст из ИИ без правки, это видно сразу — и на защите вы не сможете пересказать его. Также не заливайте в репозиторий лишнее: личные заметки, пароли, ключи.

Читать подробнее: GitHub Docs — Hello World

15 Домашнее задание

Доработайте проект по замечаниям ревью и защиты и залейте его на GitHub.

Что нужно сделать

  • ☐ Исправить замечания, которые вы приняли на ревью и защите.
  • ☐ Проверить все пункты MVP из раздела 02 и пройти чек-лист ревьюера по своему проекту ещё раз.
  • ☐ Убрать из кода лишние console.log, закомментированный код и мёртвые функции.
  • ☐ Написать README.md (что это, стек, запуск, скриншот, что исправили, что можно улучшить).
  • ☐ Залить проект на GitHub: репозиторий публичный, не менее 5 осмысленных коммитов.
  • ☐ Если защиту ещё не проходили — подготовиться по разделу 13.

Что сдать

Ссылку на репозиторий в чат группы (Telegram). В сообщении напишите: вариант проекта (А или Б), что добавили после ревью, какой бонус сделали (если сделали).

Как оценивается ДЗ (0–10)

Что проверяемБаллы
Ссылка открывается без входа, репозиторий публичный, в корне есть index.html2
Замечания ревью исправлены (видно в коммитах)3
README с описанием и скриншотом, написан своими словами2
Не менее 5 осмысленных коммитов с понятными сообщениями2
Приложение открывается без ошибок в консоли1

Это оценка за домашнее задание урока (0–10). Оценка за сам мини-проект (0–100) выставляется по таблице из раздела 03 и учитывается отдельно.

✓ Шпаргалка урока

  • Проект: трекер задач (А) или привычек (Б) на чистом JS. Оценка 0–100: функциональность 25, DOM 15, события и формы 15, состояние и localStorage 15, структура кода 10, интерфейс 5, защита 15; бонус до +5.
  • MVP сначала: форма с валидацией, отметка, удаление, фильтры, счётчик, сохранение. Бонусы — потом.
  • Декомпозиция с ИИ: промпт по формуле курса → вычеркнуть лишнее → проверить порядок → оценить время самому (×1.5) → шаг не длиннее 40 минут → у каждого шага видимый результат.
  • Архитектура: состояние → чистые функции (возвращают новое) → render() → commit(next) (состояние + сохранить + перерисовать) → события.
  • Список: один обработчик на родителе (делегирование), event.target.closest(...), dataset для id.
  • Форма: preventDefault, trim, пустое не добавляем. Текст пользователя — через textContent.
  • localStorage: только строки → JSON.stringify / JSON.parse; чтение в try/catch + проверка формы; ключ с префиксом "tracker:v1". В песочнице страницы он заблокирован — работаем через заменитель с тем же API.
  • id: отдельный счётчик nextId, а не length + 1.
  • Ревью: запуск (пытаемся сломать) → чтение по чек-листу → разговор: 3 «хорошо» и 3 «улучшить». Замечание = где, что вижу, почему, предложение.
  • Защита (5 мин): 0:30 представление, 1:30 демо с F5, 1:30 код (state, render, обработчик), 1:30 вопросы. Любую строку объясняем своими словами.
  • GitHub: git init → add → commit → remote add origin → push -u origin main; README со скриншотом; коммиты осмысленные и по-английски.