Місяць 2 · Заняття 06

Від «працює» до «зрозуміло»

Довести інтерфейс сервісу до стану, у якому чужа людина впорається з ним без жодної підказки автора.

90 хвилин розбір зауважень 12 хв теорія 15 хв практика 53 хв підсумок 10 хв

Що ти зробиш сьогодні

Ти зробиш так, щоб сторінка зрозуміло поводилася у трьох випадках: коли записів у базі немає, коли вони є і коли сервер не відповів на запит. Кожне поле форми отримає свій підпис, кнопка видалення — питання «точно видалити?», а сама сторінка — розміри й кольори, з якими вона читається на телефоні шириною 360 пікселів.

Наприкінці ти впишеш назви своїх елементів у розділ pages файлу project.yaml. Саме звідти автоперевірка дізнається, де на твоїй сторінці шукати список, окремий запис, форму й текст для порожнього стану — сама вона їх не вгадує.

Теорія

1. Інтерфейс і три стани списку

Інтерфейс — усе, що людина бачить на сторінці й чим користується: заголовки, тексти, поля, кнопки, повідомлення. Код бекенду вона не бачить ніколи. Якщо запис не додався, а на екрані нічого не змінилося, для неї зламався весь сервіс, і причина її не цікавить.

Стан — вигляд сторінки в конкретній ситуації. Список записів має три стани, і кожен виникає в реального користувача:

  • є дані — у базі є записи, сторінка показує їх;
  • порожньо — запит виконався успішно, але записів поки що жодного. Так сторінка виглядає для кожного, хто відкрив сервіс уперше;
  • помилка — запит не виконався. Сервер перезапускається, або на телефоні зник інтернет, або помилка в коді ендпоінта. Ендпоінт — окрема адреса твого сервера, за якою він віддає дані: api/habits у тебе саме такий.

Є ще четвертий, короткий стан — завантаження: запит уже пішов, відповідь ще не прийшла. На швидкому інтернеті він триває частку секунди, у метро — кілька секунд.

Приклад із життя. Ти вперше відкриваєш новий месенджер. Списку розмов ще немає. Якщо на екрані просто біле поле — ти вирішиш, що застосунок не завантажився, і закриєш його. Якщо там написано «Розмов ще немає. Натисни „плюс“, щоб почати першу» — ти знаєш і що сталося, і що робити далі.

Звідси правило для тексту будь-якого стану: одне речення про те, що сталося, і одне про те, що робити. «Записів ще немає. Додай перший через форму вище» — цього достатньо. «Помилка» без пояснень не годиться: людина не знає, чи винна вона, чи сервіс.

Порожній стан і помилка мають різні тексти, бо вимагають різних дій. При порожньому стані треба щось додати. При помилці додавати марно — треба оновити сторінку за хвилину. Якщо обидві ситуації показують один напис, людина робитиме не те.

Ти зробив два стани списку: «є дані» і «порожньо». Стану «помилка» немає — при невдалому запиті сторінка показує той самий текст, що й при порожній базі. Людина, у якої вже є 20 записів, відкриває сторінку в момент, коли сервіс перезапускається. Що вона побачить і що зробить?

2. Підписи до полів і ті, хто читає сторінку не очима

label — тег-підпис, прив’язаний до конкретного поля вводу. Прив’язка робиться двома атрибутами: у поля є id (власна назва елемента на сторінці), а в підпису — for з точно таким самим значенням.

<label for="f-title">Назва звички</label>
<input id="f-title" name="title" type="text">

for="f-title" і id="f-title" — те саме слово, символ у символ. Якщо в одному місці написати f-title, а в іншому ftitle, прив’язки не буде, хоча на екрані все виглядатиме однаково.

Часта заміна підпису — placeholder: сірий текст усередині порожнього поля. Він виглядає акуратно й економить місце, але для підпису не годиться з трьох причин:

  • він зникає, щойно людина починає писати. Заповнюючи третє поле, вона вже не бачить, що мало бути в першому;
  • він сірий і часто не проходить за контрастом — про це наступний розділ;
  • частина програм, що читають сторінку вголос, його не читає.

Екранний диктор — програма, яка читає вміст сторінки вголос людині, що не бачить екрана. Вона є в кожній операційній системі: у Windows це NVDA і «Екранний диктор», в Android — TalkBack, в iPhone — VoiceOver. Така програма читає не картинку, а розмітку. Дійшовши до поля вводу, вона шукає його label і промовляє: «Назва звички, поле для тексту». Якщо label немає — вона скаже «поле для тексту» й нічого більше, і людина не дізнається, що туди писати.

Підпис допомагає й тим, хто бачить екран. Натискання на label ставить курсор у прив’язане поле. На телефоні це означає, що ціль для пальця стає вдвічі більшою: промахнутися по підпису й полю разом важче, ніж по самому полю.

Приклад із життя. Форма реєстрації з трьома однаковими порожніми прямокутниками, у яких сірим написано «Введіть», «Введіть», «Введіть». Ти набрав перше значення, і всі три підказки стали неактуальними: перша зникла, а дві інші однакові. Це та сама помилка, яку робить більшість авторів перших сервісів.

У твоїй формі поля label не мають, зате в кожному є сірий placeholder із назвою поля. На екрані все зрозуміло. Твій сервіс відкриває незряча людина через екранний диктор. Що станеться?

3. Семантична розмітка

Семантична розмітка — це коли тег обирають за змістом того, що всередині, а не за тим, як воно має виглядати. Заголовок сторінки — h1, шапка — header, основний вміст — main, розділ — section, перелік — ul зі вкладеними li, кнопка — button.

Протилежність — <div>. Це тег без власного значення: усередину нього кладуть будь-який вміст, і для браузера це не означає нічого конкретного. Виглядати він може як завгодно, бо вигляд задає CSS. Тому будь-яку кнопку технічно можна зробити з div, і на екрані різниці не буде.

Різниця з’являється поза екраном, і вона в трьох речах:

  • клавіатура. Клавіша Tab переходить між елементами, з якими можна щось зробити: полями, посиланнями, кнопками. button у цей перелік потрапляє сам, div — ні. Людина, яка не користується мишею (зламаний тачпад, травма руки), до твоєї кнопки з div просто не дістанеться;
  • екранний диктор. Дійшовши до button, він каже «кнопка Видалити». Дійшовши до div з тим самим текстом — просто «Видалити», і людина не знає, що з цим можна щось зробити;
  • поведінка за замовчуванням. button спрацьовує не тільки від кліка, а й від Enter і Пробіл. На div усе це довелося б дописувати вручну.

Приклад із життя. На сайті школи розклад зроблено картинками замість таблиці. Ти не можеш ані скопіювати з нього рядок, ані знайти пошуком своє прізвище, ані нормально відкрити його з телефона. Виглядає воно при цьому нормально. Це та сама історія: вигляд правильний, розмітка ні.

Практичне правило на сьогодні: перш ніж написати div, перевір, чи немає тега з потрібним значенням. Для сьогоднішньої сторінки вистачить шести: header, main, section, h1h2, ul/li, button, form.

Замість <button>Видалити</button> ти зробив <div class="btn">Видалити</div> і повісив на нього обробник кліка. Мишею все працює, виглядає однаково. Що саме зламалося?

4. Сторінка на екрані шириною 360

CSS-піксель — одиниця, у якій задають розміри в розмітці. Це не точка матриці. Телефон із матрицею на 1080 точок у ширину показує сторінку так, ніби її ширина 360 CSS-пікселів, бо кожен CSS-піксель у нього намальовано трьома точками. Тому 360 — це не «поганий старий телефон», а звичайний сучасний. Саме під це число перевіряють сторінки.

Щоб браузер телефона рахував ширину саме так, у <head> потрібен один рядок:

<meta name="viewport" content="width=device-width, initial-scale=1">

Viewport — видима область сторінки у вікні браузера. width=device-width означає «вважай ширину сторінки рівною ширині екрана». Без цього рядка браузер телефона робить вигляд, що екран широкий (близько 980 пікселів), і показує всю сторінку зменшеною — текст стає нечитабельним. Цей рядок уже є у твоєму ~/www/index.html з першого заняття; сьогодні просто перевір, що ти його не видалив.

Горизонтальна прокрутка — коли сторінку доводиться тягати вбік, щоб побачити її праву частину. Вертикальна прокрутка нормальна, горизонтальна — ні: людина не бачить, що там праворуч, і не здогадується туди тягнути. Виникає вона тоді, коли хоч один елемент ширший за екран. Причин зазвичай п’ять:

  • ширина задана числом: width: 600px на екрані в 360;
  • до ширини 100% додано ще й поля. За звичайних налаштувань браузер додає внутрішні поля понад задану ширину, тож width: 100% плюс padding: 16px дає 100% + 32 пікселі;
  • довге слово чи посилання без пробілів — адреса, назва файлу, ідентифікатор. Розірвати його браузеру нема де, тож він розсовує сторінку;
  • таблиця, картинка або блок коду, який ширший за екран і нічим не обмежений;
  • white-space: nowrap на довгому тексті — прямий наказ не переносити рядок.

Друге, що стосується телефона, — розмір цілі для натискання. Один CSS-піксель — це приблизно 0.26 мм екрана, тож 44 CSS-пікселі дають ціль близько 7 міліметрів. Саме 44 рекомендує Apple, Google називає близьке число — 48 (приблизно 9 мм). Обидва числа взяті з того, скільки місця накриває палець дорослої людини. Кнопка заввишки 20 пікселів вимагає прицілювання, а кнопка «видалити» поруч із кнопкою «відкрити» на такій висоті дає помилкові натискання регулярно.

Третє — розмір шрифту в полях вводу. Якщо в поля шрифт менший за 16 пікселів, браузер на iPhone при натисканні на поле збільшує всю сторінку, і після введення вона лишається збільшеною. Ставити 16 пікселів у полях простіше, ніж потім пояснювати це користувачам.

Приклад із життя. Ти відкриваєш з телефона сайт із розкладом секції. Заголовок видно, а праву частину таблиці — ні, і сторінка їздить убік від кожного дотику. Ти закриваєш її й пишеш тренерові запитання, відповідь на яке була на цій сторінці.

На ноутбуці сторінка виглядає добре, а на телефоні їздить убік. Ти виміряв: сторінка ширша за екран рівно на 32 пікселі. У формі є поле з width: 100% і padding: 16px. Що відбувається?

5. Контраст тексту

Коефіцієнт контрасту — число, яке показує, наскільки колір тексту відрізняється за яскравістю від кольору фону. Рахують його за формулою: кожен колір спершу перетворюють на одне число яскравості від 0 до 1, потім до обох чисел додають 0.05 і ділять більше на менше. Додаток 0.05 потрібен, щоб чорний колір із яскравістю рівно 0 не давав ділення на нуль. Результат лежить між 1:1 і 21:1:

ТекстФонКонтрастВисновок
#000000#FFFFFF21:1максимум, який узагалі буває
#1A1A1A#FFFFFF≈17:1звичайний основний текст
#767676#FFFFFF4.54:1найсвітліший сірий, який ще проходить
#999999#FFFFFF2.85:1не проходить
#CCCCCC#FFFFFF1.61:1практично не видно

Мінімум для звичайного тексту — 4.5:1. Для великого тексту (від 24 пікселів, або від 18.66 пікселя напівжирного) допускають 3:1, але простіше тримати 4.5 скрізь і не рахувати винятки.

Чому це не формальність. Твій екран новий, яскравість повна, ти сидиш у кімнаті. Той самий текст читатимуть на трирічному телефоні з подряпаною плівкою, на сонці на зупинці, при нічній яскравості 10%, і читатиме людина, у якої зір мінус чотири й окуляри залишилися вдома. При контрасті 2.85:1 у половині цих випадків текст просто зникає.

Звідки береться низький контраст у першому сервісі, майже завжди:

  • сірий колір для «другорядного» тексту — дат, підписів, приміток. Саме там опиняється #999;
  • властивість opacity: 0.6 на блоці. Вона робить напівпрозорим увесь блок разом із текстом, і контраст падає непомітно для автора;
  • кольоровий текст на кольоровому фоні: помаранчевий на бежевому, світло-зелений на білому;
  • сірий placeholder усередині поля, який автор використав замість підпису.

Перевіряти контраст на око не можна: людина погано оцінює різницю яскравості двох кольорів, а до кольорів власної сторінки ще й звикає за кілька годин роботи. На кроці 9 практики ти напишеш невеликий скрипт на Python, який рахує це число точно.

Дати записів ти зробив кольором #999999 на білому — виглядає спокійно й акуратно. Скрипт каже: 2.85:1 при потрібних 4.5:1. Що це означає на практиці?

6. Дії, які не можна скасувати

Видалення запису з бази незворотне: рядок зникає, і повернути його можна тільки з резервної копії. Кнопка, яка робить це від одного натискання, рано чи пізно спрацює випадково. На телефоні, де сусідні кнопки стоять за пів сантиметра одна від одної, перше таке натискання зазвичай трапляється в перший же тиждень.

Найпростіша перевірка — питання перед дією. У браузері для нього є вбудована команда confirm: вона показує невелике вікно з текстом і двома кнопками, «ОК» і «Скасувати», і повертає true або false залежно від того, що людина натиснула. Вигляд цього вікна задає сам браузер: у Chrome, Firefox і Safari воно різне — і за розташуванням, і за порядком кнопок. Зате в межах одного браузера воно однакове на всіх сайтах, тож людина його впізнає. Ані розмітки, ані стилів воно не потребує.

Текст питання має називати конкретний запис, а не дію взагалі. «Ви впевнені?» не дає жодної інформації: людина не бачить, що саме зникне, і натискає «ОК» за звичкою. «Видалити запис „Пробіжка, 3 вересня“? Скасувати не вийде» — тут видно і що зникає, і що це назавжди.

Приклад із життя. У галереї телефона ти виділив 20 фотографій, натиснув «видалити» і побачив питання «Видалити 20 фото? Їх можна буде відновити протягом 30 днів». Саме через це питання ти встиг помітити, що виділив зайве. У твоєму сервісі кошика немає: після запиту на видалення рядок зникає з бази остаточно. Тому питання перед дією — єдина перевірка, яка в нього закладена.

Мама відкрила твій сервіс із телефона, хотіла подивитися запис і натиснула кнопку «Видалити» — вона стояла поруч і зникла разом із записом. Що правильно зробити в коді?

Слова цього уроку

СловоЩо означає
інтерфейсУсе, що людина бачить на сторінці й чим користується
станВигляд сторінки в конкретній ситуації: порожньо, є дані, помилка, завантаження
семантична розміткаТег обирають за змістом (header, button, ul), а не за виглядом
labelТег-підпис, прив’язаний до конкретного поля через for та id
idВласна назва елемента на сторінці; на одній сторінці не повторюється
placeholderСірий текст усередині порожнього поля; зникає під час введення, підпис не замінює
екранний дикторПрограма, що читає розмітку сторінки вголос незрячій людині
CSS-піксельОдиниця розмірів у розмітці; на телефоні їй відповідає кілька точок матриці
viewportВидима область сторінки; рядок meta viewport прирівнює її до ширини екрана
горизонтальна прокруткаСторінку доводиться тягати вбік, бо якийсь елемент ширший за екран
box-sizing: border-boxПравило, за яким поля й рамка входять усередину заданої ширини
коефіцієнт контрастуЧисло від 1:1 до 21:1: різниця яскравості тексту й фону. Мінімум 4.5:1
селектор CSSКороткий запис, яким вказують на елемент: #list — з id="list", .item — з class="item"
hiddenАтрибут, який приховує елемент; з JavaScript керується через елемент.hidden
confirmВбудоване вікно браузера з питанням; повертає true або false
браузер без вікнаСправжній браузер, запущений без екрана — ним автоперевірка відкриває твою сторінку
APIНабір адрес, за якими дані забирає програма, а не людина; у тебе вони починаються з api/
ендпоінтОкрема адреса всередині API, за якою сервер віддає щось конкретне: api/habits
nginxПрограма на сервері, яка приймає всі запити з інтернету й вирішує, кому їх передати
службаТвоя програма на Python, яку система запускає сама й тримає запущеною; зветься club-api
код відповідіТризначне число, яким сервер каже, чим скінчився запит: 200 — успіх, 404 — адреси немає, 422 — дані не пройшли перевірку, 500 — помилка в коді сервера
комітЗбережений у git знімок стану файлів разом із підписом про те, що змінено
репозиторійТека, за змінами в якій стежить git; у тебе це ~/app
async / awaitПозначки для функцій, які чекають на відповідь ззовні: await — «зачекай на відповідь і тільки потім іди далі», async — «усередині цієї функції є await»

Практика

Приклади написані для щоденника звичок: таблиця habit, поля title, day, minutes, ендпоінт списку api/habits. Скрізь підставляй свої назви — ті, що в тебе в project.yaml і в main.py.

1 Розібрати список зауважень

Дістань записи, які ти зробив удома, коли давав сервіс іншій людині. Розділи їх на три групи прямо на аркуші:

  1. не зрозуміла, що робити — не знайшла форму, не зрозуміла, що вписувати в поле, не побачила, що запис додався;
  2. не влучила чи не побачила — промахнулася по кнопці, не помітила текст, довелося збільшувати сторінку;
  3. справді зламано — помилка, порожній екран, запис не зберігся.

Третю групу лагодь останньою: там зазвичай один-два пункти. Перші дві — це саме те, що ти сьогодні виправляєш, і майже все в них стосується речей, які тобі здавалися очевидними.

Проти кожного пункту постав, який крок цього заняття його закриває. Пункти, які не закриває жоден, підуть у домашнє завдання.

2 Зайти на сервер і зберегти копію сторінки

ssh ЛОГІН@91.219.61.4

Замість ЛОГІН — свій, наприклад bohdan.g9. Пароль під час введення не показується: ні крапок, ні зірочок.

Сьогодні ти переписуватимеш сторінку великими шматками. Перед цим збережи те, що працює:

cp ~/www/index.html ~/backups/index-$(date +%F).html
ls -l ~/backups/

$(date +%F) підставляє сьогоднішню дату у вигляді 2026-09-06, тож копії різних днів не затирають одна одну. Якщо сторінка перестане відкриватися, повернути попередній варіант можна командою cp у зворотному напрямку.

3 Зробити каркас сторінки

nano ~/www/index.html

Спершу перевір <head>: там мають бути рядок meta charset, рядок meta viewport і <title> з назвою твого продукту. Заголовок вкладки бачить людина в переліку вкладок і в закладках; Document або Скоро тут буде сервіс там бути не повинно.

<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Щоденник звичок — Богдан</title>

Тепер тіло сторінки. Заміни те, що є всередині <body>, на таку структуру:

<header>
  <h1>Щоденник звичок</h1>
  <p>Записую, скільки хвилин на день витрачаю на кожну звичку. Автор: Богдан.</p>
</header>

<main>
  <section>
    <h2>Додати запис</h2>
    <form id="add-form">
      <!-- поля будуть на кроці 6 -->
    </form>
    <p id="form-msg"></p>
  </section>

  <section>
    <h2>Мої записи</h2>
    <!-- три стани будуть на кроці 4 -->
    <ul id="list"></ul>
  </section>
</main>

<!-- блок script із заняття 05 лишається нижче, його заміниш на кроці 5 -->

Самі теги <body> і </body> у блоці не наведені навмисно: вони у файлі вже є, і копіювати їх удруге не треба. Заміняєш саме те, що між ними — але блок <script> наприкінці не чіпай: він лишається у файлі до кроку 5.

Що тут важливо:

  • header — шапка сторінки, main — основний вміст, section — окремий змістовий розділ. Диктор і клавіатура використовують ці теги, щоб переходити між частинами сторінки;
  • h1 на сторінці один — це назва самого сервісу. h2 — заголовки розділів. Пропускати рівні (з h1 одразу на h3) не треба;
  • речення під h1 пояснює, що це за сервіс і для кого. Людина, яка потрапила сюди вперше, має зрозуміти це за п’ять секунд;
  • id="add-form" і id="list" — назви, за якими JavaScript і автоперевірка знаходять форму й список. На кроці 12 ти впишеш їх у project.yaml;
  • <p id="form-msg"> — порожній абзац під формою. У нього код напише, чому сервер не прийняв запис. Такий самий абзац був у тебе на занятті 05; залиш його, бо з ним працює код із кроку 5;
  • <ul> порожній: записи в нього додає JavaScript після відповіді сервера.

Зберегти — Ctrl+O, потім Enter. Вийти — Ctrl+X.

4 Додати три стани списку

Усередину розділу «Мої записи», перед рядком <ul id="list"></ul>, який уже стоїть у файлі з кроку 3, додай три абзаци:

<p id="loading">Завантажую записи…</p>
<p id="empty" hidden>Записів ще немає. Додай перший через форму вище.</p>
<p id="error" hidden>Не вдалося завантажити записи. Онови сторінку за хвилину.</p>

Сам <ul id="list"> другий раз додавати не треба: двох елементів з однаковим id на сторінці бути не може, і JavaScript тоді знайде не той.

hidden — атрибут, який приховує елемент: браузер його не показує, поки атрибут стоїть. Тексти при цьому лежать у файлі сторінки, а JavaScript тільки вирішує, який із них показати. Це важливо ще з однієї причини: автоперевірка читає HTML-файл і шукає в ньому текст порожнього стану. Якщо цей текст існує лише всередині JavaScript і збирається під час роботи, скрипт його не знайде.

loading видно одразу, бо запит по дані ще не завершився. Далі спрацює один із трьох варіантів: показати список, показати «порожньо» або показати «помилка».

Тексти пиши свої й до свого сервісу. «Записів ще немає» для щоденника звичок, «Ти ще не додав жодної гри» для списку ігор. Слово «Порожньо» саме по собі не пояснює нічого — воно не каже, що робити далі.

5 Навчити JavaScript перемикати стани

Знайди в кінці файлу блок <script>, який ти додав на занятті 05, і заміни його цілком — від рядка <script> до рядка </script> — на цей. Замінюємо весь блок, а не одну функцію: старий код звертається до елементів, яких після кроку 3 на сторінці вже немає, а якщо лишити старі рядки поруч із новими, у файлі буде двічі оголошено const list, і браузер не виконає жодного рядка скрипту.

<script>
const API = 'api/habits';                  // ← адреса твого ендпоінта

const form    = document.querySelector('#add-form');
const list    = document.querySelector('#list');
const loading = document.querySelector('#loading');
const empty   = document.querySelector('#empty');
const errBox  = document.querySelector('#error');
const formMsg = document.querySelector('#form-msg');

async function loadItems() {
  loading.hidden = false;
  empty.hidden   = true;
  errBox.hidden  = true;
  list.innerHTML = '';

  try {
    const response = await fetch(API);
    if (!response.ok) throw new Error(response.status);
    const items = await response.json();

    loading.hidden = true;

    if (items.length === 0) {
      empty.hidden = false;
      return;
    }

    for (const item of items) {
      const li = document.createElement('li');
      li.className = 'item';
      li.textContent = item.day + ' — ' + item.title + ', ' + item.minutes + ' хв';
      list.append(li);
    }
  } catch (e) {
    loading.hidden = true;
    errBox.hidden  = false;
  }
}

form.addEventListener('submit', async function (event) {
  event.preventDefault();
  formMsg.textContent = '';

  const response = await fetch(API, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({                   // ← твої поля, назви з кроку 6
      title:   document.querySelector('#f-title').value,
      day:     document.querySelector('#f-day').value,
      minutes: Number(document.querySelector('#f-minutes').value)
    })
  });

  if (response.ok) {
    form.reset();
    await loadItems();
  } else if (response.status === 422) {
    formMsg.textContent = 'Перевір поля: назва не може бути порожня.';
  } else {
    formMsg.textContent = 'Сервер відповів помилкою ' + response.status;
  }
});

loadItems();
</script>

Поля #f-title, #f-day і #f-minutes з’являться на кроці 6 — зараз форма ще порожня, і відправляти в ній нічого. Код звертається до них усередині обробника, тобто в момент натискання кнопки, тому до кроку 6 він просто не виконується й помилки не дає.

Розбір по частинах:

  • document.querySelector('#list') знаходить елемент за селектором. Селектор CSS — короткий запис, яким вказують на елемент: #list означає «елемент з id="list"», .item — «усі елементи з class="item"»;
  • async перед словом function означає «усередині цієї функції є очікування відповіді», а await перед запитом — «зачекай на відповідь і тільки потім іди далі». Без await код помчав би далі, поки сервер ще думає, і в наступному рядку відповіді ще не було б. Обидва слова ти вже писав на занятті 05;
  • перші чотири рядки функції повертають сторінку в початковий вигляд: показати «завантажую», сховати решту, очистити список. Без цього при повторному виклику старі записи лишилися б на екрані поруч із новими;
  • list.innerHTML = '' — стерти весь вміст списку. innerHTML — це вся розмітка всередині елемента одним рядком; присвоїти їй порожній рядок означає прибрати звідти всі <li>;
  • fetch(API) — запит по дані. Адреса відносна: браузер добудує її від адреси сторінки й отримає /u/ЛОГІН/api/habits. Тому та сама сторінка працюватиме й тоді, коли на занятті 10 у сервісу з’явиться власне доменне ім’я;
  • код відповіді — тризначне число, яким сервер повідомляє, чим скінчився запит. 200 і сусідні означають «зроблено», 404 — «такої адреси немає», 422 — «дані не пройшли перевірку», 500 — «помилка в коді на сервері». response.ok — це true саме для кодів від 200 до 299. Якщо код інший, throw перериває виконання й передає керування в catch;
  • try і catch — те саме, що try і except у Python: якщо всередині try щось пішло не так, виконується другий блок. Слово інше, зміст той самий. Сюди потрапляють і обрив мережі, і код 500 від сервера;
  • items.length === 0 — список порожній. Тоді показуємо «порожньо» і виходимо з функції через return;
  • textContent вставляє текст саме як текст. Якщо людина напише в назві символи < і >, вони лишаться символами й не стануть частиною розмітки. Якби той самий рядок вставили через innerHTML, назва <script>…</script> стала б розміткою й виконалася б у браузері кожного, хто відкриє твою сторінку;
  • li.className = 'item' — назва класу для кожного рядка списку. За нею його знаходить і CSS, і автоперевірка;
  • обробник submit спрацьовує при натисканні кнопки в формі. event.preventDefault() зупиняє стандартне перезавантаження сторінки, далі значення полів пакуються в JSON і йдуть на сервер методом POST. Якщо сервер відповів 422, у #form-msg з’являється текст про перевірку полів.

6 Дати кожному полю підпис

Заповни форму add-form полями свого сервісу. Приклад для трьох полів:

<form id="add-form">
  <p>
    <label for="f-title">Назва звички</label>
    <input id="f-title" name="title" type="text" required maxlength="200">
  </p>
  <p>
    <label for="f-day">Дата</label>
    <input id="f-day" name="day" type="date" required>
  </p>
  <p>
    <label for="f-minutes">Скільки хвилин</label>
    <input id="f-minutes" name="minutes" type="number" min="0" max="600" value="0">
  </p>
  <button type="submit">Додати запис</button>
</form>

Що робить кожен атрибут:

  • for у підпису і id у поля — однакове значення, символ у символ. Це і є прив’язка;
  • name — назва поля, під якою значення піде на сервер. Вона має збігатися з назвою в моделі із заняття 05 — це клас у main.py, у якому перелічено, які поля сервер очікує в запиті й якого вони типу. Такі класи описують бібліотекою Pydantic, звідси й назва «модель Pydantic». Якщо назви не збігаються, сервер відповість кодом 422 — «дані не пройшли перевірку»;
  • required — браузер не дасть відправити форму з порожнім полем і сам покаже підказку. Це зручність для людини, а не захист: перевірка на сервері все одно потрібна, бо запит можна надіслати й повз форму;
  • type="date" відкриває календар, type="number" на телефоні показує цифрову клавіатуру замість літерної;
  • maxlength, min, max — межі значень. Вони теж лише для зручності: справжні межі стоять у моделі на сервері;
  • type="submit" у кнопки означає «відправити форму». Текст кнопки — дія, яку вона виконує: «Додати запис», а не «ОК».

Підпис має бути в кожного поля вводу без винятку. Якщо якесь поле, на твою думку, і так зрозуміле — підпис усе одно потрібен: автоперевірка рахує їх усі, і людина з диктором теж.

Звір назви полів із кодом. На занятті 05 поля звалися інакше — f-title і f-days, і на сервер ішло days. Якщо в тебе тепер f-day і f-minutes, ті самі три назви мають стояти в трьох місцях: у id поля, у JSON.stringify з кроку 5 і в моделі в main.py. Розбіжність в одному місці дає або 422, або порожнє значення в базі.

7 Запитувати підтвердження перед видаленням

У циклі, який будує рядки списку (крок 5), додай кнопку видалення після рядка li.textContent = …:

const del = document.createElement('button');
del.type = 'button';
del.textContent = 'Видалити';
del.addEventListener('click', async function () {
  const yes = confirm('Видалити запис «' + item.title + '»? Скасувати не вийде.');
  if (!yes) return;
  await fetch(API + '/' + item.id, { method: 'DELETE' });
  loadItems();
});
li.append(del);

Розбір:

  • del.type = 'button' — тег button без явного типу вважається кнопкою відправлення форми. Ця кнопка лежить у <li> поза формою, тож зараз це ні на що не впливає; але щойно така кнопка опиниться всередині <form>, вона відправить форму й перезавантажить сторінку. Тому тип пишуть явно завжди;
  • confirm показує вікно з питанням і повертає true, якщо натиснули «ОК», і false, якщо «Скасувати». У тексті питання стоїть назва конкретного запису — людина бачить, що саме зникне;
  • if (!yes) return; — натиснули «Скасувати», виходимо з функції й нічого не робимо;
  • method: 'DELETE' — той самий запит на видалення, який ти зробив на занятті 05. API — константа з першого рядка скрипту, тож адреса виходить api/habits/17;
  • loadItems() наприкінці перечитує список із сервера. Якщо в базі не лишилося записів, сторінка сама перемкнеться на стан «порожньо» — окремо це програмувати не треба.

8 Задати розміри й кольори

Стилів у твоєму ~/www/index.html ще немає: з першого заняття в <head> стоять тільки meta charset, meta viewport і <title>. Тому створи блок <style> — постав його в <head> одразу після </title> — і встав усередину це. Кольори підбирай свої, але контраст перевір на кроці 9.

<style>
  /* сюди піде код, наведений нижче */
</style>
* { box-sizing: border-box; }

body {
  margin: 0;
  padding: 16px;
  background: #FFFFFF;
  color: #1A1A1A;
  font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
}

header, main { max-width: 640px; margin: 0 auto; }

h1 { font-size: 24px; line-height: 1.25; margin: 0 0 6px; }
h2 { font-size: 19px; margin: 28px 0 10px; }

label {
  display: block;
  margin-bottom: 4px;
  font-weight: 600;
}

input {
  width: 100%;
  max-width: 340px;
  padding: 10px 12px;
  font-size: 16px;
  color: #1A1A1A;
  background: #FFFFFF;
  border: 1px solid #767676;
  border-radius: 6px;
}

button {
  min-height: 44px;
  padding: 10px 18px;
  font-size: 16px;
  color: #FFFFFF;
  background: #8A2C0D;
  border: 0;
  border-radius: 6px;
  cursor: pointer;
}

#list { list-style: none; margin: 0; padding: 0; }

.item {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
  justify-content: space-between;
  padding: 12px 0;
  border-bottom: 1px solid #D6DDE3;
  overflow-wrap: anywhere;
}

img, table { max-width: 100%; }

Навіщо кожен блок:

  • * { box-sizing: border-box; } — зірочка означає «кожен елемент сторінки». Правило змінює спосіб рахувати ширину: поля й рамка тепер входять усередину заданої ширини, а не додаються до неї. Це прибирає найчастішу причину горизонтальної прокрутки;
  • #1A1A1A на білому дає контраст близько 17:1, білий текст на #8A2C0D — близько 8.6:1. Обидва проходять із запасом;
  • max-width: 640px обмежує ширину тексту на великому екрані. Рядок на всю ширину монітора читати важче: після кінця такого рядка довше шукати початок наступного;
  • label { display: block } ставить підпис окремим рядком над полем. Так підпис не стоїть поруч із полем і не забирає в нього ширину на телефоні;
  • font-size: 16px у полях — щоб браузер на iPhone не збільшував сторінку при натисканні на поле;
  • min-height: 44px у кнопок — розмір цілі під палець;
  • overflow-wrap: anywhere дозволяє розірвати довге слово, якщо воно не вміщується. Без цього одна довга адреса в назві запису розсуває всю сторінку. Значення саме anywhere, а не звичніше break-word: у .item стоїть display: flex, а всередині flex браузер рахує мінімальну ширину по найдовшому нерозривному слову, і break-word цього числа не зменшує;
  • img, table { max-width: 100% } — картинка чи таблиця не буде ширшою за свій контейнер;
  • display: flex у .item ставить текст запису й кнопку в один рядок, а flex-wrap: wrap дозволяє кнопці перейти на наступний рядок, коли місця мало.

9 Порахувати контраст

Створи невеликий скрипт, який рахує коефіцієнт контрасту двох кольорів за офіційною формулою:

nano ~/app/contrast.py
"""Коефіцієнт контрасту двох кольорів.
Запуск:  /opt/club/venv/bin/python contrast.py 999 ffffff
"""
import sys


def normalize(hex_color):
    """Приводить колір до шести символів: #999 і 999 → 999999."""
    hex_color = hex_color.lstrip("#")
    if len(hex_color) == 3:
        hex_color = "".join(c + c for c in hex_color)
    if len(hex_color) != 6:
        sys.exit(f"Це не схоже на колір: {hex_color}")
    return hex_color


def luminance(hex_color):
    """Яскравість кольору числом від 0 (чорний) до 1 (білий)."""
    hex_color = normalize(hex_color)
    channels = [int(hex_color[i:i + 2], 16) / 255 for i in (0, 2, 4)]
    linear = []
    for c in channels:
        linear.append(c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4)
    r, g, b = linear
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


if len(sys.argv) != 3:
    sys.exit("Запуск: contrast.py КОЛІР_ТЕКСТУ КОЛІР_ФОНУ, наприклад 999 ffffff")

text_color, bg_color = sys.argv[1], sys.argv[2]
a, b = luminance(text_color), luminance(bg_color)
light, dark = max(a, b), min(a, b)
ratio = (light + 0.05) / (dark + 0.05)

verdict = "проходить" if ratio >= 4.5 else "ЗАМАЛО, треба 4.5"
print(f"{text_color} на {bg_color}: {ratio:.2f}:1 — {verdict}")

Що робить формула: кожен із трьох каналів кольору (червоний, зелений, синій) переводиться з діапазону 0–255 у частку від одиниці, потім вирівнюється степеневою функцією — так робиться тому, що око сприймає яскравість нерівномірно. Далі три числа складаються з різною вагою: зелений впливає на яскравість найсильніше (0.7152), синій найслабше (0.0722). Отримані яскравості двох кольорів ділять одна на одну, додавши 0.05, щоб чорний не давав ділення на нуль.

Перевір усі свої пари «текст — фон»:

cd ~/app
/opt/club/venv/bin/python contrast.py 1A1A1A FFFFFF
/opt/club/venv/bin/python contrast.py FFFFFF 8A2C0D
/opt/club/venv/bin/python contrast.py 999999 FFFFFF
/opt/club/venv/bin/python contrast.py 999 FFFFFF

Перші два мають дати «проходить», третій — «ЗАМАЛО»: це той самий світло-сірий із теорії, і скрипт має його відхилити. Четвертий — той самий колір у короткому записі з трьох символів, який часто пишуть у CSS; функція normalize подвоює кожен символ, тож результат має збігтися з третім рядком. Якщо скрипт відхиляє щось із твоїх кольорів, зроби колір тексту темнішим або фон світлішим і порахуй ще раз.

Запускати треба саме /opt/club/venv/bin/python — це той Python, у якому на сервері встановлено бібліотеки курсу. Той самий шлях ти писав на заняттях 02 і 04. Просто python3 теж запустить цей скрипт (сторонніх бібліотек у ньому немає), але звикай до одного повного шляху, щоб не плутатися далі.

Якщо в тебе на сторінці є блок @media (prefers-color-scheme: dark) з окремими кольорами для темної теми, порахуй і його пари теж. Автоперевірка дивиться на світлу тему, але людина з увімкненою темною побачить саме твої темні кольори.

10 Перевірити сторінку на ширині 360

Спочатку на комп’ютері. Відкрий свою сторінку в браузері, натисни F12 — відкриються інструменти розробника, службова панель браузера. У ній є режим перегляду сторінки як на телефоні: іконка з двома прямокутниками або Ctrl+Shift+M. Постав ширину 360 і перевір три речі:

  • сторінка не їздить убік;
  • кнопки натискаються пальцем без прицілювання;
  • усі підписи й тексти видно повністю, нічого не обрізано.

Якщо сторінка все-таки їздить убік, знайди елемент, який виходить за межі екрана. Перейди у вкладку Console (там виконують команди JavaScript просто на відкритій сторінці) і встав цей код:

document.querySelectorAll('*').forEach(function (el) {
  const r = el.getBoundingClientRect();
  if (r.right > document.documentElement.clientWidth + 1) {
    console.log(el.tagName, el.className, Math.round(r.right));
  }
});

Код проходить по всіх елементах сторінки й друкує ті, чий правий край виходить за межі екрана: назву тега, клас і координату правого краю. Перший рядок у списку — і є джерело проблеми, решта зазвичай лежить усередині нього.

Потім відкрий сторінку на власному телефоні: http://91.219.61.4/u/ЛОГІН/. Режим у браузері показує розміри, але не показує, як воно натискається пальцем.

11 Перевірити порожній стан

Автоперевірка дивиться на сторінку з порожнім списком. Побачити те саме, не видаляючи своїх записів, можна так: тимчасово додай умову, якої не виконує жоден рядок, у SQL-запит — той рядок у main.py, яким твоя програма питає в бази дані (SELECT * FROM habit ORDER BY id DESC означає «віддай усі рядки таблиці habit, новіші згори»). Відкрий ~/app/main.py і в ендпоінті списку заміни запит:

return query("SELECT * FROM habit WHERE 1 = 0 ORDER BY id DESC")

Умова 1 = 0 хибна для кожного рядка, тож запит поверне порожній список. Записи при цьому лишаються в базі недоторканими. Перезапусти службу й онови сторінку:

systemctl --user restart club-api

systemctl — команда, якою в Linux керують службами. Ключ --user означає «моя власна служба, а не загальносерверна», club-api — назва твоєї служби, тієї програми на Python, яку система тримає запущеною. Ці самі команди ти писав на заняттях 02, 03 і 05. Без перезапуску правки в main.py ні на що не вплинуть: служба тримає в пам’яті ту версію коду, з якою її запустили.

Ти маєш побачити свій текст порожнього стану, а не порожнє місце й не «завантажую». Якщо все правильно — поверни запит як був і перезапусти службу ще раз:

nano ~/app/main.py
systemctl --user restart club-api

Не забудь повернути запит. Якщо лишити WHERE 1 = 0, пункт L06.1 зарахується, а L04.3 із четвертого заняття перестане проходити: список віддаватиме нуль записів, хоча в базі вони лишаються.

Стан «помилка» перевіряється ще простіше: зупини службу, онови сторінку, подивись на текст, запусти службу назад.

systemctl --user stop club-api
# подивись на сторінку в браузері
systemctl --user start club-api

Якщо після stop сторінка все одно показує список. У частині груп служби налаштовані так, що система піднімає їх від першого ж запиту. Тоді зупинена служба стартує назад, щойно сторінка звернеться до api/, і помилки ти не побачиш. Це не поломка. Перевір стан «помилка» інакше: у main.py тимчасово зміни адресу ендпоінта списку (наприклад, api/habits на api/habits-x), перезапусти службу — сторінка отримає код 404 і покаже текст помилки. Потім поверни адресу як була.

12 Заповнити розділ pages і зробити коміт

nano ~/app/project.yaml

Знайди розділ pages і впиши селектори своїх елементів:

pages:
  index: "/"
  list_container: "#list"
  item: ".item"
  empty_state: "#empty"
  form: "#add-form"

Що означає кожен рядок:

  • index — адреса головної сторінки відносно твого сайту. Лишай /;
  • list_container — селектор елемента, усередині якого лежать записи. У прикладі це <ul id="list">;
  • item — селектор одного запису. У прикладі кожен <li> отримує class="item", тому .item;
  • empty_state — селектор абзацу з текстом «записів ще немає»;
  • form — селектор форми додавання. Саме всередині неї автоперевірка шукатиме поля й підписи до них.

Значення мають збігатися з тим, що справді написано в HTML. Якщо в розмітці id="items", а в project.yaml#list, скрипт нічого не знайде й пункт не зарахує.

Тепер збережи зміни в git. Репозиторій — тека, за змінами в якій стежить git; у тебе це ~/app. Сторінка лежить не там, а в ~/www, тому git її не бачить і ніколи не побачить — спершу треба покласти в репозиторій її копію. Файл www-index.html ти завів для цього ще на занятті 03:

cp ~/www/index.html ~/app/www-index.html
cd ~/app
git add -A
git commit -m "Три стани списку, підписи до полів, підтвердження видалення"
git push origin master

Порядок команд саме такий і має значення. Коміт — збережений у git знімок стану файлів разом із підписом про те, що змінено; у знімок потрапляє тільки те, що лежало в репозиторії на момент git add. Якщо копіювати сторінку після коміта, у ньому будуть project.yaml і contrast.py, а сторінки, названої в повідомленні, — ні.

git push origin master надсилає коміти в спільне сховище. Гілка в наших проєктах зветься master; ту саму команду ти писав на заняттях 03, 04 і 05. Слово main тут не спрацює: такої гілки в репозиторії немає, і git відповість src refspec main does not match any.

Який файл головний. На занятті 03 порядок був зворотний: правити ~/app/www-index.html, а потім копіювати в ~/www/. Сьогодні кожну правку треба одразу дивитися в браузері, тому всі 12 кроків правлять саме ~/www/index.html, а в репозиторій копіюють готовий результат наприкінці. Головний файл — той, що в ~/www/. ~/app/www-index.html — його копія для git, а не другий варіант сторінки: редагувати його не треба, він щоразу перезаписується командою cp.

Що перевіряє автоматика

Чотири пункти. Перший — обов’язковий мінімум заняття: без нього решта не рахується. Усі чотири скрипт перевіряє сам, викладач тут нічого не оцінює очима.

Три з чотирьох пунктів вимагають браузера без вікна — це справжній браузер, запущений на сервері без екрана: він відкриває твою сторінку, виконує JavaScript, будує розмітку й дає скрипту виміряти те, що вийшло. Через це має значення, чи працює твоя сторінка насправді, а не тільки те, що написано у файлі.

КодКритерійЩо робить скриптЯкщо не зараховано
L06.1 порожня база: сторінка пояснює, що записів немає Бере селектор із pages.empty_state і перевіряє, що такий елемент на сторінці є і в ньому є текст щонайменше з трьох слів. Далі робить копію твоєї бази, прибирає з копії всі записи й відкриває сторінку так, щоб список прийшов порожнім. На екрані має бути видно саме цей текст, а не порожнє місце й не напис «завантажую». Твоя справжня база не змінюється. Перевір по черзі: чи заповнено pages.empty_state; чи збігається селектор з id у розмітці; чи лежить текст у самому HTML, а не збирається в JavaScript; чи знімає код рядок loading.hidden = true перед тим, як показати порожній стан. Перевірити своїми очима — крок 11.
L06.2 кожен input має свій label Знаходить форму за селектором з pages.form, збирає всі поля вводу всередині неї (input, select, textarea, крім прихованих і кнопок) і для кожного шукає підпис: або <label for="…"> зі значенням, що дорівнює id поля, або label, усередині якого це поле лежить. Достатньо одного поля без підпису, щоб пункт не зарахувався. Найчастіше причина — друкарська помилка: for та id відрізняються однією літерою або регістром. Друга причина — поле, яке ти вважав очевидним і лишив без підпису: поле дати, поле кількості. Третя — форма з іншим id, ніж записано в pages.form.
L06.3 немає горизонтальної прокрутки на 360 px Відкриває сторінку у вікні шириною 360 пікселів, дочікується, поки завантажиться список, і порівнює повну ширину вмісту з шириною вікна. Якщо вміст ширший — пункт не проходить. Знайди елемент, що виходить за межі екрана, кодом із кроку 10: він друкує такі елементи списком. Найчастіші причини — забутий рядок * { box-sizing: border-box; }, ширина в пікселях замість відсотків, довге слово чи адреса в назві запису без overflow-wrap: anywhere, картинка чи таблиця без max-width: 100%.
L06.4 контраст тексту не нижчий за 4.5:1 Бере основні текстові елементи сторінки — заголовки, абзаци, підписи, записи списку, — зчитує колір тексту й колір фону, який під ним фактично опинився, і рахує коефіцієнт контрасту за тією самою формулою, що й твій contrast.py. Мінімум для кожного — 4.5. Прогони свої пари кольорів через contrast.py (крок 9). Дивись у бік світло-сірих кольорів для дат і приміток та властивості opacity: вона робить напівпрозорим текст разом із блоком і знижує контраст непомітно. Якщо не знаєш, який саме елемент не пройшов, подивись у кабінеті — скрипт пише туди назву тега й отримане число.

Домашнє завдання

  1. Закрити щонайменше три пункти зі списку зауважень. Це ті пункти з кроку 1, які не закрив жоден крок заняття. У повідомленні кожного коміта пиши, який саме пункт закрито: git commit -m "Кнопка додавання не була видна на телефоні — перенесено під форму". На наступному занятті ці повідомлення читатимуть.
  2. Показати сервіс ще одній людині. Іншій, не тій, що на занятті 05. Дай адресу, нічого не пояснюй і засікай час: скільки минуло від відкриття сторінки до першого доданого запису. Запиши число й те, на чому вона зупинялася.
  3. Перевірити з телефона в поганих умовах. Відкрий сторінку надворі при денному світлі й з яскравістю екрана десь на чверть. Те, що зникає, — саме те, що не пройде за контрастом.
  4. Заголовок вкладки й опис. Перевір, що <title> містить назву твого продукту, а перший абзац під h1 одним реченням пояснює, що це і для кого. Найкоротша перевірка: прочитай це речення вголос комусь, хто про твій проєкт не чув.
  5. Порожній стан для помилки. Переконайся, що текст стану «помилка» не збігається з текстом стану «порожньо» і що обидва пояснюють, що робити далі. Перевір обома способами з кроку 11.

На початку наступного заняття буде питання «поясни рядок»: викладач покаже будь-який рядок із твого index.html — з розмітки, зі стилів або зі скрипту — і попросить сказати, що він робить.

Якщо щось не працює

Текст «записів ще немає» видно завжди, навіть коли записи є

Атрибут hidden у розмітці не проставлено, або JavaScript не знімає його правильно. Перевір два місця. Перше: у файлі має бути <p id="empty" hidden> — саме зі словом hidden у тезі. Друге: у функції loadItems перший блок рядків має ховати обидва повідомлення (empty.hidden = true, errBox.hidden = true) перед запитом, і тільки потім, за результатом, один із них показуватися.

Протилежна ситуація — порожній стан не показується ніколи — зазвичай означає, що loading лишився видимим і перекрив логіку. Перевір, що рядок loading.hidden = true виконується до перевірки items.length === 0.

Автоперевірка не бачить підписів, хоча вони на сторінці є

Порівняй for та id символ у символ: f-title і ftitle — різні значення, F-Title і f-title теж. Швидко подивитися всі пари можна прямо на сервері:

grep -o 'for="[^"]*"' ~/www/index.html
grep -o 'id="[^"]*"'  ~/www/index.html

grep -o друкує не цілі рядки, а лише те, що збіглося зі зразком. Кожному значенню з першого списку має відповідати таке саме в другому. Друга можлива причина — поля лежать поза формою: скрипт шукає їх усередині елемента з pages.form, і поле, яке стоїть після </form>, він не побачить, але й підпис до нього не порахує.

Сторінка все одно їздить убік, хоча box-sizing додано

Запусти код із кроку 10 у вкладці Console і подивись на перший елемент у списку. Три найчастіші причини, яких box-sizing не усуває:

  • довгий текст без пробілів у назві запису — адреса, назва файлу. Додай overflow-wrap: anywhere елементу .item. Саме anywhere: у .item стоїть display: flex, а там значення break-word мінімальної ширини не зменшує;
  • ширина, задана числом: width: 500px замість max-width: 500px. Різниця в тому, що max-width дозволяє елементу стати вужчим, коли екран малий;
  • таблиця. Якщо на сторінці є таблиця, загорни її в <div style="overflow-x: auto"> — тоді вбік їздитиме сама таблиця всередині свого блоку, а не вся сторінка.

Список порожній, а в базі записи є

Це стан «помилка», який ти прийняв за «порожньо», або навпаки. Відкрий інструменти розробника (F12), вкладку Network — там видно всі запити сторінки. Онови сторінку й знайди рядок із api/. Дивись на код відповіді:

  • 200, а в тілі відповіді [] — сервер відповів «зроблено», але список порожній: [] — це порожній список у форматі JSON, дві квадратні дужки без нічого всередині. Найімовірніше, у запиті лишилася умова WHERE 1 = 0 з кроку 11;
  • 404 — «такої адреси немає»: адреса в fetch не збігається з адресою в @app.get. Перевір, що в fetch написано 'api/habits' без косої на початку: коса на початку означає корінь сервера, а не твою теку;
  • 500 — помилка в коді самого ендпоінта. Текст помилки шукай у журналі служби (це записи, які твоя програма надрукувала під час роботи): journalctl --user -u club-api -n 30.

Змінив файл, а в браузері старий вигляд

Спершу перевір, що змінився саме той файл: сторінку віддає ~/www/index.html, а не копія в ~/app. Потім онови сторінку з очищенням збереженої копії — Ctrl+Shift+R (на Mac Cmd+Shift+R): браузер зберігає файли сторінок у себе, щоб не завантажувати їх щоразу, і без цього може показувати вчорашній варіант.

Якщо після редагування сторінка стала порожньою або зникла її частина, найімовірніше десь незакритий тег або лапка. Найшвидший спосіб знайти — вкладка Console: помилки JavaScript із номером рядка вона показує прямо. Якщо помилка в розмітці, поверни збережену на кроці 2 копію й вноси зміни меншими шматками:

ls ~/backups/
cp ~/backups/index-2026-09-06.html ~/www/index.html