IlmHamroh
JavaScript Full-stack/11-qism. Asinxron JavaScript va tarmoq32/32-dars75 daqiqa
Mundarija (48)

JavaScript yakuniy loyiha: saqlanadigan va sinxronlanadigan vazifalar ilovasi (v3)

Qisqacha: Bu darsda vazifalar ilovasi serverga ulanadi. Ro'yxat ochilishda darhol brauzer xotirasidan (keshdan) chiziladi, keyin «Bahor» mashq API'sidagi nusxa bilan solishtiriladi. Har o'zgarish avval ekranda ko'rinadi, keyin serverga ketadi; server rad etsa — ro'yxat oldingi holatiga qaytadi. Filtr URL'da, tablar o'zaro sinxron, internet yo'qligi esa ekranda aytiladi.

Bu darsda

  • 11-qism davomida qo'shilgan qadamlarni — taymer, localStorage, URL filtr, tablar sinxroni, API klient — bitta vazifalar v3 ga yig'asiz.
  • "Avval kesh, keyin server" tartibida ishga tushadigan ilova yozasiz va eskirgan so'rovni AbortController bilan bekor qilasiz.
  • Optimistik yangilashni (optimistic update) va xatoda qaytarishni (rollback) qo'lda yozasiz — server ataylab rad etganda ham ro'yxat buzilmaydi.
  • Yuklanish, xato va oflayn holatlarini foydalanuvchiga ko'rsatasiz.
  • Bir qarorni o'lchab qayta ko'rib chiqasiz: dinamik import() endi foyda bermaydi, shuning uchun olib tashlanadi.

Oldin bilishingiz kerak: Yakuniy loyiha: vazifalar v2, Bekor qilish: AbortController, API klient qatlami, localStorage va sessionStorage, location va History API, Oyna va tablar orasida aloqa.

1. Nega bu kerak?

Sardor — «Bahor»ning stajyor dasturchisi. U vazifalar ilovasini har kuni ishlatadi va 11-qism davomida unga qadamma-qadam yangi imkoniyat qo'shdi. Endi ro'yxat brauzerda saqlanadi, filtr URL'da turadi, ikki tab bir-birini ko'radi. Lekin hammasi bitta brauzerning ichida qoladi.

Ertaga Sardor ro'yxatni serverda ham saqlamoqchi. Shunda bir nechta muammo birdan chiqadi:

  • Server sekin. Katakni bosgan zahoti javob kelmaydi. Agar ilova javobni kutib tursa, har bosish yarim soniya "qotadi".
  • Server rad etishi mumkin. Ekranda "bajarildi" deb ko'rsatdik, server esa xato qaytardi. Endi ekran yolg'on gapiryapti.
  • Internet uziladi. Metroda yoki liftda tarmoq yo'q. Ilova buni bilishi va aytishi kerak.
  • Eski javob yangisini bosib ketadi. Ro'yxat so'rovi yo'lda turganda Sardor vazifa qo'shdi. Kech kelgan eski javob yangi vazifani ekrandan "o'chirib" yuborishi mumkin.

Telegram'ni eslang. Xabar yozib yuborsangiz, u chatda darhol paydo bo'ladi — yonida kichik soat belgisi bilan. Server qabul qilgach, soat belgisi "" ga aylanadi. Internet bo'lmasa, xabar yonida qizil belgi chiqadi. Telegram serverni kutib, ekranni qotirmaydi. Lekin yolg'on ham gapirmaydi. Bugun vazifalar ga aynan shu xulqni beramiz.

Yangi narsa kam: crypto.randomUUID(), navigator.onLine va online/offline hodisalari. Qolgani — qismning 31 darsi, bitta loyihada uchrashadi.

2. Loyiha talablari

2.1 Ilova nima qiladi

v3 — bu v2 ning hamma imkoniyati (qo'shish, belgilash, tahrir, o'chirish, filtr, saralash, teglar, eksport va import) va yana oltita yangilik:

  1. Kesh. Ochilishda ro'yxat darhol localStorage dan chiziladi — server javobini kutmasdan (localStorage).
  2. Server bilan sinxron. Ro'yxat «Bahor» mashq API'sida ham saqlanadi: https://ilmhamroh.uz/api/mashq/vazifalar/:kalit. Har brauzerning o'z kaliti bor (API klient qatlami).
  3. Optimistik yangilash va qaytarish. O'zgarish darhol ko'rinadi; server rad etsa — ro'yxat oldingi holatiga qaytadi va sababi e'lon qilinadi.
  4. URL filtr. ?filtr=faol — havola bilan ulashiladi, «Orqaga» tugmasi ishlaydi (History API).
  5. Tablar sinxroni. Bir tabdagi o'zgarish boshqasida darhol ko'rinadi (Tablar orasida aloqa).
  6. Holatlar. "Server bilan solishtirilmoqda…", "Serverga ulanib bo'lmadi" va «Qayta urinish» tugmasi, "Internet yo'q".

2.2 Haqiqat qayerda turadi

Endi ro'yxatning uchta nusxasi bor: ekrandagi (holat.vazifalar), brauzer xotirasidagi va serverdagi. Qaysi biri to'g'ri? Bu savolga oldindan javob bermasangiz, nusxalar bir-biridan uzoqlashib ketadi.

Haqiqat manbai (source of truth) — ziddiyat chiqsa, qaysi nusxa to'g'ri hisoblanishini belgilaydigan joy. Bizda u — server. localStorage esa kesh (cache): tez qo'l ostida turadigan nusxa. Xuddi telefoningizdagi kontaktlar kabi: ular telefonda bor, internetsiz ham ochiladi. Lekin bulut bilan sinxron bo'lganda, bulutdagi nusxa ustun.

Bundan uchta qoida chiqadi:

  • Ochilishda keshni ko'rsatamiz (tez), keyin serverdan haqiqiy ro'yxatni olib, kesh va ekranni yangilaymiz.
  • Har o'zgarishdan keyin serverdan ro'yxatni qayta so'raymiz — server nima desa, oxirida ekranda shu bo'ladi.
  • Server "bu kalitni bilmayman" desa (birinchi ochilish yoki server qayta ishga tushgan), unga o'zimizdagi nusxani yuboramiz.

Optimistik yangilash (optimistic update) — o'zgarishni server javobini kutmasdan ekranda ko'rsatish. "Server ko'pincha rozi bo'ladi" degan umid bilan, ya'ni optimistik. Uning jufti — qaytarish (rollback): server rad etsa, ekranni o'zgarishdan oldingi holatga qaytarish. Telegram'dagi soat belgisi — aynan optimistik yangilash.

Butun g'oya to'rt qadam: nusxa ol → ekranda o'zgartir → serverni kut → rad etsa, nusxani qaytar. Ilovasiz, oddiy massivda ko'ramiz. "Server" bu yerda 300 ms dan keyin rad etadigan soxta funksiya:

js
const ekran = ["Non olish"];

function serverdaQosh() {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error("Server rad etdi")), 300);
  });
}

async function qosh(matn) {
  const oldingi = [...ekran]; // 1. nusxa
  ekran.push(matn); // 2. darhol ekranda
  console.log("Ekranda:", ekran.join(", "));
  try {
    await serverdaQosh(); // 3. server javobini kutamiz
  } catch (xato) {
    ekran.splice(0, ekran.length, ...oldingi); // 4. qaytarish
    console.log(`Saqlanmadi: ${xato.message}`);
  }
  console.log("Oxirida:", ekran.join(", "));
}

qosh("Choy damlash");
console.log("Foydalanuvchi kutib qolmadi");

Konsolda:

text
Ekranda: Non olish, Choy damlash
Foydalanuvchi kutib qolmadi
Saqlanmadi: Server rad etdi
Oxirida: Non olish

Tartibga qarang. "Choy damlash" server javobidan oldin ko'rindi va foydalanuvchi kutib qolmadi — bu optimistik qism. 300 ms dan keyin server rad etdi va ro'yxat nusxadan tiklandi — bu qaytarish. Nusxa birinchi qadamda olingani muhim: o'zgarishdan keyin olingan "nusxa" eski holatni eslay olmaydi. Ilovadagi serverdaBajar ham aynan shu to'rt qadam, faqat haqiqiy server bilan.

Diqqat: Mashq API vazifalarni server xotirasida saqlaydi. Server qayta ishga tushsa yoki ro'yxatga 7 kun tegilmasa — u o'chadi; foydalanuvchilar juda ko'payib ketsa, eng eski ro'yxatlar ham o'chiriladi. Ilovamiz bunga tayyor: server ro'yxatni "unutsa", kalit va kesh brauzerda qoladi va ro'yxat keshdan tiklanadi. Haqiqiy ma'lumot uchun o'z backend'ingiz va bazangiz kerak — buni 25-qismdan boshlab vazifalar-api da quramiz.

Tekshirib ko'ring: Kesh — "Non olish" bajarilmagan, server — bajarilgan. Ilova ochilgach, oxirida ekranda qaysi holat bo'ladi va nega?

Javob

Bajarilgan. Avval keshdagi holat bir lahza ko'rinadi, keyin server javobi keladi. Haqiqat manbai — server, shuning uchun ekran va kesh server nusxasiga almashadi. Masalan, boshqa qurilmadan o'zgartirilgan bo'lsa, shunday bo'lishi kerak.

2.3 Maqsadli arxitektura

text
vazifalar/
├── index.html
├── package.json
├── README.md
├── assets/
│   ├── css/vazifalar.css
│   └── js/
│       ├── api.js         ← API klient darsidan
│       ├── asosiy.js
│       ├── holat.js
│       ├── marshrut.js    ← URL va History darslaridan
│       ├── paket.js
│       ├── render.js
│       ├── royxat.js
│       ├── saqlash.js     ← localStorage darsidan
│       ├── sinxron.js     ← tablar darsidan
│       └── vazifa.js
└── tekshiruv/
    ├── api.js
    ├── marshrut.js
    ├── paket.js
    ├── royxat.js
    ├── saqlash.js
    ├── ssenariy.md
    └── xotira.js

Yangi to'rt modul:

Fayl Nima uchun javob beradi Eksport qiladi
saqlash.js kesh va server kaliti SAQLASH_KALITI, saqla, yukla, kalitniOl
marshrut.js filtr URL'da filtrniOl, filtrniYoz
sinxron.js tablar orasida xabar tarqat, tingla
api.js server bilan gaplashish ApiXatosi, apiKlient

vazifa.js, royxat.js va paket.js 10-qism yakunidagidek — bitta harf ham o'zgarmaydi (vazifalar v2). Klasslar va JSON formati to'g'ri qurilgani uchun ular serverga ulanishni sezmaydi ham. marshrut.js va sinxron.js ham History API va tablar darslaridagi holicha.

Kim kimni import qiladi:

flowchart TD
  A["asosiy.js<br/>hodisalar, sinxron"] --> R["render.js"]
  A --> H["holat.js"]
  A --> AP["api.js<br/>server"]
  A --> SX["sinxron.js<br/>tablar"]
  A --> M["marshrut.js<br/>URL"]
  A --> SQ["saqlash.js<br/>kesh, kalit"]
  R --> H
  H --> SQ
  H --> RO["royxat.js"]
  SQ --> P["paket.js"]
  P --> V["vazifa.js"]
  RO --> V
  R --> V

Diagrammaga qarang: strelka "import qiladi" degani. asosiy.js boshqa modullarning hammasini ham import qiladi — rasm sodda qolishi uchun ularning bir qismi chizilmagan. Strelkalar faqat pastga: aylana yo'q. Yangi api.js, marshrut.js va sinxron.js hech kimni import qilmaydi — ular faqat brauzer API'si bilan ishlaydi. fetch faqat api.js da, localStorage faqat saqlash.js da uchraydi.

3. Reja: sakkiz bosqich

# Bosqich Natija Tayanch dars
1 Branch va ssenariy tekshiruv/ssenariy.md v3 vazifalar v2
2 O'lchab qaror qilish paket.js yana statik Dinamik import()
3 saqlash.js va api.js kalit, PUT, sinov rejimi API klient
4 holat.js va render.js tarmoq holati, fokus Dinamik UI'da qulaylik
5 Kesh, keyin server sinxronla() AbortController
6 Optimistik yangilash serverdaBajar() async/await
7 Oflayn va xato online/offline Ushlanmagan xatolar
8 Tekshiruv va PR Node, Chrome, Pages GitHub CLI

Ish tartibi 10-qism yakunidagidek: avval fayldagi kodni o'zingiz yozing yoki ko'zdan kechiring, keyin yechim bilan solishtiring. Har bosqich oxirida ilova ochiladi va ishlaydi. main ga faqat to'liq tekshirilgan holat tushadi.

4. 1-bosqich: branch va ssenariy

bash
cd ~/kurs/vazifalar
git switch main
git pull
git switch -c feature/v3

Avval o'zingizni tekshiring: 11-qism qadamlari main da bormi? Quyidagi jadvaldagi har qatorni faylingizda toping. Biror qadamni o'tkazib yuborgan bo'lsangiz — o'sha darsning «Vazifalar qadami» mashqiga qayting.

Fayl Nima bo'lishi kerak Dars
render.js elonQil — e'lon 3 s da o'zi yo'qoladi Taymerlar
asosiy.js paketniYukla() — await import("./paket.js") Dinamik import()
api.js ApiXatosi, apiKlient (royxat, qosh, yangila, ochir) API klient
saqlash.js saqla, yukla — "vazifalar:v3" localStorage
marshrut.js filtrniOl, filtrniYoz; popstate URL, History
sinxron.js tarqat, tingla; boshqaTabdanYangila — xabarda paket Tablar

Endi tekshiruv ro'yxati. v2 ning 14 qadamli oltin ssenariysi (vazifalar v2) o'zgarmaydi — foydalanuvchi uchun eski xulq aynan shunday qoladi. Unga v3 qadamlari qo'shiladi. Ularni Chrome DevTools'ning Network va Application panellari bilan bajarasiz:

# Harakat Kutilgan natija
15 Birinchi ochilish GET → versiya: 0 → PUT; Application'da vazifalar:kalit
16 Qo'shish, belgilash, tahrir, o'chirish har biri: POST/PATCH/DELETE, keyin GET
17 F5 (Network: Slow 4G) ro'yxat darhol keshdan, "Server bilan solishtirilmoqda…", keyin xabar yo'qoladi
18 Ikkinchi tab, unda qo'shish birinchi tabda ham darhol paydo bo'ladi
19 1-tabda tahrir ochiq, 2-tabda qo'shish 1-tabda yozilayotgan matn va fokus joyida
20 «Faol» → «Bajarilgan» → «Orqaga» manzil ?filtr=faol, «Faol» bosilgan
21 ?muvaffaqiyatsiz=1: katakni bosish darhol belgilanadi, so'ng qaytadi; "Saqlanmadi: Sinov uchun ataylab xato"
22 ?muvaffaqiyatsiz=1: o'chirish va qo'shish vazifa yo'qolib qaytadi; yangisi paydo bo'lib yo'qoladi
23 Network: Offline "Internet yo'q. Ro'yxat — shu brauzerdagi nusxa."
24 Oflaynda katakni bosish katak o'zgarmaydi; "Internet yo'q — o'zgarish saqlanmaydi."
25 Network: No throttling xabar yo'qoladi, GET ketadi
26 Slow 4G, F5 va darhol katak birinchi GET — (canceled), belgilash saqlanadi

Ikkala jadvalni tekshiruv/ssenariy.md ga ko'chiring va commit qiling:

bash
git add tekshiruv/ssenariy.md
git commit -m "Docs: v3 ssenariysini yoz"

5. 2-bosqich: o'lchab qaror qilish — paketniYukla olib tashlanadi

Dinamik import() darsida biz paket.js ni faqat «JSON qilib chiqarish» bosilganda yuklaydigan qildik. G'oya to'g'ri edi: eksport kodi ko'p foydalanuvchiga kerak emas, uni boshida yuklash — ortiqcha ish.

Keyin localStorage darsida saqlash.js paydo bo'ldi. U paket.js ni oddiy import bilan oladi — saqlangan ro'yxatni ochilishdayoq paketniOqi bilan o'qish kerak. Tablar orasida aloqa darsida asosiy.js ning o'zi ham uni statik import qildi — tablar xabari uchun. Bugun esa server javobi ham paketniOqi dan o'tadi. Demak paket.js endi ilovaning boshidanoq kerak.

Taxmin qilmaymiz — o'lchaymiz. Sahifani Network panelida ("JS" filtri) ikki holatda ochdik:

  • asosiy.js da statik import bo'lmaganda (paket.js ni faqat saqlash.js statik import qiladi, modulepreload yo'q): ochilishda 10 ta .js so'raldi, paket.js — eng oxirida. Brauzer uni saqlash.js ni o'qib bo'lgachgina "topadi" va keyin so'raydi.
  • Statik import va modulepreload bilan: yana 10 ta .js, lekin paket.js qolganlari bilan birga, boshida so'raladi.

Ya'ni dinamik import() localStorage darsidan beri hech narsani tejamaydi: fayl baribir yuklanadi, faqat kechroq. Ortida esa ortiqcha kod qoldi — async tinglovchilar, null tekshiruvi, yuklash xatosi uchun xabar. Bu kod o'z ishini yo'qotdi.

Qaror: paketniYukla() olib tashlanadi, statik import va modulepreload qaytadi. Bu "dinamik import noto'g'ri edi" degani emas. Og'ir va kam ishlatiladigan modul uchun (grafik kutubxonasi, PDF yasovchi, matn muharriri) u hamon eng yaxshi yo'l. Faqat sharoit o'zgardi, qaror ham o'zgarishi kerak. Real ishda bu ko'p uchraydi: optimallashtirish o'lchov bilan boshlanadi va o'lchov bilan qayta ko'rib chiqiladi.

asosiy.js — oldin:

js
async function paketniYukla() {
  try {
    return await import("./paket.js");
  } catch (xato) {
    elonQil("Eksport moduli yuklanmadi. Sahifani yangilang.");
    console.error(xato);
    return null;
  }
}

Keyin — bu funksiya yo'q. Uning ishini importlar orasidagi qator bajaradi — u tablar darsidan beri bor:

js
import { eksportMatni, paketniOqi } from "./paket.js";

Eksport va import tinglovchilari yana oddiy (async siz) funksiyalarga qaytadi — ularning to'liq ko'rinishi bosqichlar oxiridagi asosiy.js da. index.html ga esa o'sha darsda o'chirilgan qator qaytadi:

html
<link rel="modulepreload" href="assets/js/paket.js">
bash
git add assets/js/asosiy.js index.html
git commit -m "Eksport: paket.js ni yana statik import qil"

Tekshirib ko'ring: paket.js 200 KB lik kutubxona bo'lganda va faqat eksport tugmasida kerak bo'lganda — dinamik import() qolishi kerakmidi?

Javob

Ha — agar uni boshqa hech bir modul statik import qilmasa. Unda boshlang'ich yuklash 200 KB ga yengillashadi. Bizning holatda esa saqlash.js uni baribir boshida yuklaydi. Shuning uchun dinamik import() faqat kodni murakkablashtirardi. Qaror moduldan emas, import grafigidan chiqadi.

6. 3-bosqich: saqlash.js va api.js — kalit va ikki qo'shimcha

6.1 Server kaliti — crypto.randomUUID()

Mashq API har ro'yxatni kalit bilan ajratadi: /api/mashq/vazifalar/<kalit>. Kalit — tasodifiy, takrorlanmas satr. Uni bir marta yaratib, brauzerda saqlab qo'yamiz.

UUID (universally unique identifier) — 36 belgili tasodifiy identifikator, masalan 46f8baf9-b0fe-49fe-9179-b0396860f813. Uni ikki marta tasodifan bir xil yaratish amalda imkonsiz — xuddi ikki odamning barmoq izi kabi. Brauzerda ham, Node'da ham uni crypto.randomUUID() yasaydi.

js
const kalit = crypto.randomUUID();
console.log(kalit.length); // 36
console.log(kalit === crypto.randomUUID()); // false

Har chaqiruv — yangi kalit. Shuning uchun uni bir marta yaratib, saqlab qo'yish kerak: aks holda har ochilishda server uchun "yangi foydalanuvchi" bo'lib qolamiz.

saqlash.js ga — yukla dan keyin — yangi funksiya:

js
const KALIT_NOMI = "vazifalar:kalit";

// Shu brauzerning serverdagi ro'yxati: kalit bir marta yaratiladi
export function kalitniOl() {
  try {
    let kalit = localStorage.getItem(KALIT_NOMI);
    if (kalit === null) {
      kalit = crypto.randomUUID();
      localStorage.setItem(KALIT_NOMI, kalit);
    }
    return kalit;
  } catch (xato) {
    console.warn(`Kalit saqlanmadi: ${xato.name}`);
    return crypto.randomUUID();
  }
}

Fayl boshidagi izoh ham yangilanadi: // saqlash.js — brauzer xotirasi: vazifalar keshi va server kaliti. saqla va yukla o'zgarmaydi.

localStorage yopiq bo'lsa (masalan, sayt ma'lumotlari bloklangan), catch yangi kalit qaytaradi. Ilova ishlayveradi, lekin har ochilishda yangi bo'sh ro'yxat bilan — saqlay olmagandan keyin boshqa iloj yo'q.

kalit — "vazifalar:kalit" ostida, ro'yxat esa "vazifalar:v3" ostida. Nega ikkalasi bitta yozuvda emas? Ro'yxat har o'zgarishda qayta yoziladi, kalit esa hech qachon. Ularni alohida saqlash — tasodifan kalitni ustidan yozib yuborish xavfini yo'qotadi.

6.2 api.js: almashtir va sinov rejimi

API klient qatlami darsida apiKlient to'rt metod bilan yozilgan edi. Bugun unga ikki narsa kerak:

  • Butun ro'yxatni yuborish. Server "bu kalitni bilmayman" desa, keshdagi ro'yxatni bitta so'rov bilan yuboramiz. Importda ham shunday. Mashq API'da buning uchun PUT /vazifalar/:kalit bor: { vazifalar: [...] } ni qabul qiladi va yaroqli, takrorlanmas id larni saqlab qoladi.
  • Sinov rejimi. Qaytarishni sinash uchun server ataylab xato berishi kerak. Mashq API buni biladi: so'rov manziliga ?muvaffaqiyatsiz=1 qo'shilsa, har yozish so'rovi (POST, PATCH, DELETE, PUT) 500 qaytaradi. GET esa odatdagidek ishlaydi.

apiKlient — oldin:

js
export function apiKlient({ asosiyUrl, kalit }) {
  const manzil = `${asosiyUrl}/vazifalar/${kalit}`;
  return {
    royxat: (sozlama) => sorovYubor(manzil, sozlama),
    // qosh, yangila, ochir
  };
}

Keyin:

js
export function apiKlient({
  asosiyUrl, kalit, muvaffaqiyatsiz = false,
}) {
  const manzil = `${asosiyUrl}/vazifalar/${kalit}`;
  // Sinov rejimi: server har yozuvga ataylab 500 qaytaradi
  const oxiri = muvaffaqiyatsiz ? "?muvaffaqiyatsiz=1" : "";
  return {
    royxat: (sozlama) => sorovYubor(manzil, sozlama),
    qosh: (matn, sozlama) => sorovYubor(manzil + oxiri, {
      ...sozlama, metod: "POST", tana: { matn },
    }),
    yangila: (id, ozgarish, sozlama) =>
      sorovYubor(`${manzil}/${id}${oxiri}`, {
        ...sozlama, metod: "PATCH", tana: ozgarish,
      }),
    ochir: (id, sozlama) => sorovYubor(`${manzil}/${id}${oxiri}`, {
      ...sozlama, metod: "DELETE",
    }),
    almashtir: (vazifalar, sozlama) => sorovYubor(manzil + oxiri, {
      ...sozlama, metod: "PUT", tana: { vazifalar },
    }),
  };
}

ApiXatosi, sorovYubor va qolgan ichki funksiyalar — API klient darsidagidek. muvaffaqiyatsiz standart qiymati false: o'sha darsdagi tekshiruv/api.js o'zgarishsiz ishlayveradi.

Sinov rejimi qayerdan yoqiladi? Sahifa manzilidan: http://127.0.0.1:5500/?muvaffaqiyatsiz=1. Uni asosiy.js o'qiydi — keyingi bosqichlarda ko'rasiz.

bash
git add assets/js/saqlash.js assets/js/api.js
git commit -m "API klient: almashtir va sinov rejimi, server kaliti"

7. 4-bosqich: holat.js va render.js — tarmoq holati va fokus

7.1 Holatga yangi maydon

Ilova endi to'rt holatdan birida bo'ladi: server bilan solishtirilmoqda, tayyor, xato, oflayn. Bu ham holat — u holat obyektida turadi va ekranni render.js chizadi. holat.js dagi o'zgarish:

js
// Ro'yxat keshdan darhol olinadi; server bilan keyin solishtiriladi
export const holat = {
  vazifalar: VazifalarRoyxati.dan(yukla() ?? boshlangichVazifalar),
  filtr: "hammasi",
  tahrirId: null,
  tarmoq: "yuklanmoqda", // "tayyor" | "xato" | "oflayn"
};

Boshlang'ich qiymat — "yuklanmoqda": sahifa ochilganda server bilan hali gaplashmaganmiz.

7.2 aloqaniYoz — faqat bitta qator

index.html da sarlavha ostiga ikki element qo'shiladi:

html
<p id="aloqa" role="status"></p>
<button type="button" id="qayta-tugma"
        hidden>Qayta urinish</button>

render.js — elementlar ro'yxatiga va matnlar lug'ati:

js
const aloqa = document.getElementById("aloqa");
export const qaytaTugma = document.getElementById("qayta-tugma");

const aloqaMatnlari = Object.freeze({
  yuklanmoqda: "Server bilan solishtirilmoqda…",
  tayyor: "",
  xato: "Serverga ulanib bo'lmadi. Ro'yxat — shu brauzerdagi nusxa.",
  oflayn: "Internet yo'q. Ro'yxat — shu brauzerdagi nusxa.",
});

Va tarmoq qatorini chizadigan funksiya:

js
// Tarmoq holati: faqat yuqoridagi qator, ro'yxatga tegmaydi
export function aloqaniYoz() {
  matnniYoz(aloqa, aloqaMatnlari[holat.tarmoq]);
  qaytaTugma.hidden = holat.tarmoq !== "xato";
}

render() oxirida ham aloqaniYoz() chaqiriladi. Nega alohida funksiya? render() ro'yxatni butunlay qayta yasaydi (replaceChildren). Tarmoq holati o'zgarganda ro'yxatni qayta chizish shart emas — u fokusni va tahrir maydonini buzadi. aloqaniYoz() esa faqat bitta <p> ga tegadi.

#aloqa da role="status" bor: ekran o'quvchi "Internet yo'q" ni o'zi o'qib beradi (Dinamik UI'da qulaylik). "Tayyor" holatida matn bo'sh — har muvaffaqiyatli sinxronda "saqlandi" deb takrorlash foydalanuvchini charchatadi.

7.3 fondaChiz — fon yangilanishida fokus joyida qoladi

Tablar orasida aloqa darsida bitta kamchilik qolgan edi: boshqa tab ro'yxatni o'zgartirsa, bu tabdagi tahrir maydoni qayta chiziladi va unga yozilayotgan matn yo'qoladi. Endi bu muammo kuchayadi. Ro'yxat har amaldan keyin serverdan ham yangilanadi — foydalanuvchi bosmagan paytda.

Qoida oddiy: foydalanuvchi qilmagan yangilanish uning fokusini va yozayotgan matnini o'g'irlamasligi kerak. render.js ga yangi funksiya:

js
// Fon yangilanishi (server, boshqa tab, qaytarish): fokus va
// tahrirdagi matn joyida qoladi
export function fondaChiz() {
  const faol = document.activeElement;
  if (!royxat.contains(faol)) {
    render();
    return;
  }
  const id = Number(faol.closest("li").dataset.id);
  const amal = faol.dataset.amal;
  const qoralama = faol.id === "tahrir-maydon" ? faol.value : null;
  render();
  const kiritma = document.getElementById("tahrir-maydon");
  if (qoralama !== null && kiritma !== null) {
    kiritma.value = qoralama;
    kiritma.focus();
    return;
  }
  fokusla(id, amal, null);
}

Qadamma-qadam:

  1. document.activeElement — hozir fokusdagi element. U ro'yxatdan tashqarida bo'lsa (masalan, yangi vazifa maydonida), oddiy render() yetarli: maydonni render qayta yasamaydi.
  2. Ro'yxat ichida bo'lsa — qaysi qator (id) va qaysi tugma (data-amal) ekanini eslab qolamiz. Tahrir maydoni bo'lsa — yozilgan matnni ham.
  3. Chizgandan keyin: tahrir hali ochiq bo'lsa, matnni qaytarib, fokusni maydonga qo'yamiz. Aks holda fokusla o'sha qatordagi o'sha tugmani topadi. Qator o'chib ketgan bo'lsa — "Ro'yxat" sarlavhasiga o'tadi: fokus hech qachon yo'qolmaydi.

Oddiy amallarda (bosish, Enter) esa avvalgidek render() + fokusla() ishlaydi — u yerda fokus qayerga borishini foydalanuvchi amali belgilaydi.

bash
git add assets/js/holat.js assets/js/render.js index.html
git commit -m "Render: tarmoq holati va fon yangilanishida fokus"

8. 5-bosqich: avval kesh, keyin server

8.1 Ro'yxatni almashtirishning uch yo'li

v2 da ro'yxat faqat importda almashardi. Endi yana uch yo'l bor: server javobi, qaytarish va boshqa tab. Har birida yangi VazifalarRoyxati yasaladi va unga obuna qayta ulanadi — buni bitta yordamchiga yig'amiz:

js
// Yangi ro'yxat: obuna qayta ulanadi, filtr joyida qoladi
function royxatniUla(malumotlar) {
  holat.vazifalar = VazifalarRoyxati.dan(malumotlar);
  holat.vazifalar.obuna(saqlaVaChiz);
  const tahrirdagi = holat.tahrirId;
  if (tahrirdagi !== null && !holat.vazifalar.top(tahrirdagi)) {
    holat.tahrirId = null;
  }
}

Saqlash va boshqa tablarga xabar ham endi ikki joyda kerak — oddiy amalda va server javobida. Ularni ham bitta funksiyaga yig'amiz:

js
// Xotiraga va boshqa tablarga: ro'yxat xabarning o'zida ketadi
function saqlaVaTarqat() {
  saqla(holat.vazifalar);
  tarqat({ tur: "ozgardi", paket: eksportMatni(holat.vazifalar) });
}

// Ro'yxat o'zgarsa: xotiraga, boshqa tablarga, ekranga
function saqlaVaChiz() {
  saqlaVaTarqat();
  render();
}

Xabarda paket — tablar darsidagidek ro'yxatning o'zi; nega aynan shunday — keyingi bo'limda o'lchaymiz. royxatniUla ni ishlatadigan uch funksiya farqi — keyin nima qilishida:

js
// Import: ro'yxat almashadi, filtr "hammasi" ga qaytadi
function royxatniAlmashtir(malumotlar) {
  royxatniUla(malumotlar);
  holat.filtr = "hammasi";
  holat.tahrirId = null;
  filtrniYoz("hammasi");
  saqlaVaChiz();
}

// Server javobi yoki qaytarish: saqlab, fokusni joyida qoldiramiz
function royxatniYangila(malumotlar) {
  royxatniUla(malumotlar);
  saqlaVaTarqat();
  fondaChiz();
}

// Boshqa tab o'zgartirdi: ro'yxat xabarda keldi (saqlamaymiz!)
function boshqaTabdanYangila(xabar) {
  if (xabar?.tur !== "ozgardi") {
    return;
  }
  let malumotlar;
  try {
    malumotlar = paketniOqi(xabar.paket);
  } catch (xato) {
    console.warn(`Boshqa tab xabari o'qilmadi: ${xato.message}`);
    return;
  }
  royxatniUla(malumotlar);
  fondaChiz();
}
Funksiya Saqlaydi va tarqatadi Filtr Chizish
royxatniAlmashtir (import) ha "hammasi" render
royxatniYangila (server, qaytarish) ha joyida fondaChiz
boshqaTabdanYangila yo'q — aks-sado bo'lmasin joyida fondaChiz

royxatniAlmashtir — v2 dagi nom va vazifa: import. Server javobi esa foydalanuvchi tanlagan filtrga tegmasligi kerak.

8.2 Tablar: signal emas, ro'yxatning o'zi

Tablar orasida aloqa darsida xabarga ro'yxatning o'zini qo'shgan edik va "signal yuborib, yukla() bilan localStorage dan o'qish ishonchli emas" degan edik. Soddaroq variant — xabar faqat signal, { tur: "ozgardi" }, boshqa tab ro'yxatni yukla() bilan o'qiydi. G'oya chiroyli: haqiqat bitta joyda. Yakuniy sinovda shu variantni ko'p marta takrorladik — va xato topdik.

Sinov shunday edi: birinchi tabda vazifa qo'shiladi, 1,5 soniyadan keyin ikkinchi tabning ro'yxatiga qaraymiz. Sakkiz urinishdan ikki-to'rttasida ikkinchi tab xabarni oldi, lekin ekranda yangi vazifa yo'q edi. Xotirani esa bir lahzadan keyin o'qisak — vazifa joyida.

Sabab — Chrome'ning tuzilishida. Tablar odatda alohida jarayonda (process) ishlaydi. localStorage ga yozilgan o'zgarish boshqa tabga o'z yo'li bilan, BroadcastChannel xabari esa boshqa yo'l bilan boradi. Ikki yo'l orasida tartib kafolatlanmagan: ba'zan xabar o'zgarishdan oldin yetib keladi. Ikkinchi tab yukla() ni chaqiradi va eski ro'yxatni oladi. Xuddi do'stingizga "rasmni yubordim, qara" deb SMS yozganingizdek: SMS yetib keldi, rasm esa hali yuklanmoqda.

sequenceDiagram
  participant A as 1-tab
  participant X as localStorage
  participant B as 2-tab
  A->>X: saqla — yangi ro'yxat yo'lga chiqdi
  A->>B: signal: ozgardi
  B->>X: yukla
  X-->>B: ESKI ro'yxat — yangisi hali yetmagan
  Note over B: ekranda yangi vazifa yo'q

Diagrammada signalli variantning xatosi: signal ombordagi o'zgarishni quvib o'tib ketdi.

Shuning uchun ro'yxat xabarning o'zida ketadi. paket — eksportMatni yasagan o'sha JSON matn: kesh bilan bir xil format. Qabul qilgan tab uni paketniOqi bilan tekshiradi — import va server javobi o'tadigan o'sha eshikdan. Shu usul bilan sinovni 16 marta takrorladik: 16 tasida ham ikkinchi tab yangi vazifani darhol ko'rsatdi.

Bundan ikki saboq chiqadi. Birinchisi: bir marta ishlagan narsa hali "ishlaydi" degani emas — tablar, tarmoq va taymerlar bor joyda sinovni ko'p marta takrorlang. Ikkinchisi: localStorage saqlay olmasa ham (masalan, xotira to'lgan), tablar baribir sinxron qoladi.

8.3 sinxronla — server bilan solishtirish

Ilovaning yuragi. asosiy.js boshida klient yasaladi:

js
const sahifa = new URL(location.href);
const api = apiKlient({
  asosiyUrl: "https://ilmhamroh.uz/api/mashq",
  kalit: kalitniOl(),
  muvaffaqiyatsiz: sahifa.searchParams.get("muvaffaqiyatsiz") === "1",
});

Asosiy URL to'liq yozilgan: ilova GitHub Pages'da ham, Live Server'da ham bir xil serverga ulanadi. Keyin sinxron:

js
// Server bilan sinxron
let sinxronPulti = null;
let kutilmoqda = 0;

function tarmoqniBelgila(tarmoq) {
  holat.tarmoq = tarmoq;
  aloqaniYoz();
}

async function sinxronla() {
  if (kutilmoqda > 0) {
    return; // yozuvlar tugagach baribir chaqiriladi
  }
  sinxronPulti?.abort(); // eski so'rov endi kerak emas
  if (!navigator.onLine) {
    tarmoqniBelgila("oflayn");
    return;
  }
  const pult = new AbortController();
  sinxronPulti = pult;
  if (holat.tarmoq !== "tayyor") {
    tarmoqniBelgila("yuklanmoqda");
  }
  try {
    const sozlama = { signal: pult.signal };
    let { vazifalar, versiya } = await api.royxat(sozlama);
    if (versiya === 0) {
      // Server bu kalitni bilmaydi: ro'yxatni o'zimizdan yuboramiz
      const joriy = holat.vazifalar.toJSON();
      ({ vazifalar } = await api.almashtir(joriy, sozlama));
    }
    // Server javobi ham import kabi tekshiriladi
    const toza = paketniOqi(eksportMatni(vazifalar));
    if (eksportMatni(toza) !== eksportMatni(holat.vazifalar)) {
      royxatniYangila(toza);
    }
    tarmoqniBelgila("tayyor");
  } catch (xato) {
    if (xato.kod === "BEKOR") {
      return; // o'rniga yangi so'rov ketdi
    }
    console.warn(`Sinxron bo'lmadi: ${xato.message}`);
    tarmoqniBelgila(navigator.onLine ? "xato" : "oflayn");
  } finally {
    if (sinxronPulti === pult) {
      sinxronPulti = null;
    }
  }
}

Bu funksiyada to'rtta g'oya bor. Keling, birma-bir ko'ramiz.

1. Eski so'rov bekor qilinadi. sinxronPulti — yo'ldagi ro'yxat so'rovining pulti (AbortController). Yangi sinxronla() boshlansa, eskisi abort() bilan to'xtatiladi. Bu API klient qatlami darsidagi race condition (poyga holati) yechimi: "oxirgi so'rov yutadi". Bekor qilingan so'rov ApiXatosi ni BEKOR kodi bilan beradi. Bu xato emas — shunchaki o'rniga boshqa so'rov ketgan. Shuning uchun catch uni jimgina o'tkazib yuboradi.

2. versiya: 0 — server bu ro'yxatni bilmaydi. Mashq API GET ga { vazifalar, versiya } qaytaradi; versiya har yozuvda bittaga oshadi. Nol — ro'yxatga hech qachon yozilmagan: kalit yangi yoki server uni unutgan. Ikkala holatda ham to'g'ri ish bitta: keshdagi ro'yxatni PUT bilan yuborish. 11-qism davomida yig'ilgan vazifalaringiz ham shu yo'l bilan serverga ko'chadi. Agar foydalanuvchi hamma vazifani o'chirgan bo'lsa, ro'yxat bo'sh bo'ladi, lekin versiya noldan katta — demak bo'sh ro'yxat ham haqiqiy javob.

3. Server javobi ham tekshiriladi. Serverdan kelgan ma'lumot — tashqi ma'lumot, xuddi import maydonidagi matn kabi. Avval eksportMatni(vazifalar) uni bizning formatga keltiradi: oq ro'yxatdagi uch maydon qoladi, server qo'shgan teglar va yaratildi tushib qoladi. Keyin paketniOqi hamma tekshiruvni qiladi: id musbat va takrorlanmas, matn 100 belgigacha. Server boshqacha narsa yuborsa, VazifaXatosi chiqadi va catch uni "xato" holatiga aylantiradi. Ro'yxat buzilmaydi.

4. O'zgarmagan bo'lsa — hech narsa qilmaymiz. Ikkala ro'yxat bir xil JSON matnga aylansa, ekran ham, kesh ham to'g'ri. Qayta chizish, saqlash va boshqa tablarga xabar — ortiqcha ish bo'lardi.

kutilmoqda hisoblagichi keyingi bosqichda kerak bo'ladi: serverga yozuv yo'lda turganda ro'yxatni so'rash ma'nosiz — u hali eski holatni qaytaradi. «Server bilan solishtirilmoqda…» esa faqat holat "tayyor" bo'lmaganda yoziladi: fon tekshiruvi har amaldan keyin ekranda miltillamasin.

navigator.onLine — brauzerning "tarmoqqa ulanganmanmi?" degan bayrog'i. Bitta nozik joy bor: false ga ishonish mumkin, true ga esa yo'q. true faqat "Wi‑Fi yoki kabel ulangan" degani, internet haqiqatan ishlashini kafolatlamaydi. Shuning uchun catch da ham tekshiramiz: so'rov yiqildi va bayroq false bo'lsa — oflayn, aks holda — xato. navigator obyektini navigator va qurilma API'lari darsida to'liq o'rganamiz; hozir shu bitta xususiyat yetarli.

8.4 Ishga tushirish

asosiy.js oxiri:

js
// Ishga tushirish: avval keshdan darhol, keyin server
holat.filtr = filtrniOl();
holat.vazifalar.obuna(saqlaVaChiz);
render();
sinxronla();

Ochilishda nima bo'lishini vaqt bo'yicha ko'ramiz:

sequenceDiagram
  participant F as Foydalanuvchi
  participant I as Ilova
  participant K as Kesh
  participant S as Server
  I->>K: yukla
  K-->>I: eski ro'yxat
  I-->>F: ro'yxat darhol ekranda
  I->>S: GET ro'yxat
  S-->>I: haqiqiy ro'yxat
  I->>K: farq bo'lsa — saqla
  I-->>F: ekran yangilandi

Bu tartib "stale-while-revalidate" ("eskisini ko'rsat, orqada yangila") deb ataladi. Foydalanuvchi oq ekranni kutmaydi; bir soniya eski ro'yxatni ko'rsa ham, oxirida haqiqiysini ko'radi.

render() — keshdagi ro'yxat (yoki birinchi ochilishda boshlang'ich uchta vazifa) darhol ekranda. sinxronla() esa await siz chaqiriladi: ilova server javobini kutib turmaydi. Ichidagi hamma xato try/catch da ushlanadi, shuning uchun "suzib yurgan" Promise ushlanmagan xato bermaydi (Ushlanmagan xatolar).

Sinab ko'ring (Live Server, Network panelida "Fetch/XHR" filtri): birinchi ochilishda GET → PUT; PUT dan oldin OPTIONS ham ko'rinadi — bu CORS preflight (CORS mijoz tomondan). Application panelida vazifalar:kalit paydo bo'ladi. Endi F5 bosing: faqat GET.

bash
git add assets/js/asosiy.js
git commit -m "Vazifalar: avval kesh, keyin server bilan sinxron"

Tekshirib ko'ring: if (xato.kod === "BEKOR") return; qatorini o'chirsak, foydalanuvchi nimani ko'radi?

Javob

Har eski so'rov bekor qilinganda ekranda bir lahza "Serverga ulanib bo'lmadi" chiqardi, ba'zan esa «Qayta urinish» tugmasi ham. Aslida hech qanday xato yo'q — biz o'zimiz eski so'rovni to'xtatdik. Bekor qilishni xato deb ko'rsatish — AbortController darsidagi ko'p uchraydigan xato.

9. 6-bosqich: optimistik yangilash va qaytarish

9.1 serverdaBajar — bitta joyda

To'rt amal (qo'shish, belgilash, tahrir, o'chirish) va import bir xil yo'ldan yuradi: avval ekranda, keyin serverda, xatoda — qaytarish. Bu yo'lni bitta funksiyaga yig'amiz:

js
// Optimistik yangilash: avval ekranda, keyin serverda;
// server rad etsa — oldingi holatga qaytaramiz
async function serverdaBajar(mahalliy, serverda) {
  if (holat.tarmoq === "oflayn") {
    fondaChiz(); // bosilgan katak ham eski holatiga qaytsin
    elonQil("Internet yo'q — o'zgarish saqlanmaydi.");
    return;
  }
  sinxronPulti?.abort(); // yo'ldagi ro'yxat endi eskirgan
  const oldingi = holat.vazifalar.toJSON();
  mahalliy();
  kutilmoqda++;
  try {
    await serverda();
  } catch (xato) {
    royxatniYangila(oldingi);
    elonQil(`Saqlanmadi: ${xato.message}`);
  } finally {
    kutilmoqda--;
    sinxronla(); // oxirgisi tugagach — server bilan solishtirish
  }
}

Ikki parametr — ikki funksiya (callback): mahalliy ekrandagi o'zgarishni qiladi, serverda esa so'rov yuboradi va Promise qaytaradi. Qatorma-qator:

  1. Oflayn bo'lsa — boshlamaymiz. Katakni bosganda brauzer katakchani o'zi belgilab qo'yadi, hali bizning kodimiz ishlamasdan. fondaChiz() ro'yxatni holatdan qayta chizadi va katak eski holatiga qaytadi.
  2. Yo'ldagi ro'yxat so'rovi bekor qilinadi. U bu o'zgarishdan oldingi holatni qaytaradi va kech kelib, yangi vazifani ekrandan "o'chirib" yuborishi mumkin. Bu — «Nega bu kerak?» bo'limidagi to'rtinchi muammo.
  3. oldingi — nusxa. toJSON() oddiy obyektlar massivini qaytaradi: keyingi o'zgarishlar unga tegmaydi.
  4. mahalliy() — ekranda darhol. Royxat o'zgaradi, obuna orqali saqlaVaChiz ishlaydi: kesh, boshqa tablar, ekran.
  5. await serverda() — server javobini kutish. Shu paytda foydalanuvchi bemalol keyingi amalni qila oladi.
  6. Xato bo'lsa — qaytarish. Nusxadan ro'yxat qayta yasaladi, keshga yoziladi, boshqa tablarga xabar ketadi. elonQil sababini aytadi: server xabari (Sinov uchun ataylab xato) yoki ApiXatosi matni (Internet aloqasini tekshiring.).
  7. finally — har holda. Hisoblagich kamayadi va sinxronla() chaqiriladi. Yo'lda boshqa yozuv bo'lsa, u hech narsa qilmaydi. Oxirgi yozuv tugaganda esa serverdan haqiqiy ro'yxat olinadi.

Oxirgi qadam nega kerak? Ikki sabab bor:

  • Server id. Yangi vazifaga id ni ekranda VazifalarRoyxati beradi, haqiqiysini esa server. Ular odatda bir xil, lekin har doim emas: siz eng katta id li vazifani o'chirgan bo'lsangiz, server o'sha raqamni qayta ishlatmaydi. Sinxron ekrandagi id ni serverdagisiga almashtiradi.
  • Bir nechta amal birga. Ikki amal yo'lda va birinchisi rad etildi. Qaytarish ikkinchisining ekrandagi izini ham olib ketadi — nusxa undan oldin olingan. Server esa ikkinchisini qabul qilgan. Oxirgi sinxron ikkalasini tenglashtiradi.
sequenceDiagram
  actor F as Foydalanuvchi
  participant E as Ekran va kesh
  participant S as Server
  F->>E: katakni bosadi
  E->>E: oldingi nusxa
  E-->>F: darhol belgilangan
  E->>S: PATCH bajarildi
  alt server rozi
    S-->>E: 200
  else server rad etdi
    S-->>E: 500 yoki tarmoq xatosi
    E->>E: oldingi nusxa qaytadi
    E-->>F: Saqlanmadi: ...
  end
  E->>S: GET ro'yxat
  S-->>E: haqiqiy holat

Diagrammaga qarang: foydalanuvchi javobni birinchi, server javobidan oldin oladi. alt bloki — ikki yo'l: yuqorisi odatdagi, pastkisi — qaytarish. Ikkala yo'l ham oxirida bir xil GET bilan tugaydi.

9.2 Tinglovchilar

Har tinglovchi serverdaBajar ga ikki funksiya beradi. Qo'shish:

js
  xatoniTozala();
  serverdaBajar(() => {
    vazifaQosh(matn);
    filtrniYoz(holat.filtr);
    forma.reset();
    elonQil(`«${matn}» qo'shildi.`);
  }, () => api.qosh(matn));

Birinchi funksiyaning ichi — v3 gacha bo'lgan kod, so'zma-so'z. Ikkinchisi — bitta so'rov. Belgilash va o'chirish (royxat tinglovchisidagi switch):

js
    case "almashtir": {
      const bajarildi = !vazifaniTop(id).bajarildi;
      serverdaBajar(() => {
        vazifaniAlmashtir(id);
        fokusla(id, "almashtir", zaxira);
      }, () => api.yangila(id, { bajarildi }));
      break;
    }
    case "ochir": {
      const { matn } = vazifaniTop(id);
      serverdaBajar(() => {
        elonQil(`«${matn}» o'chirildi.`);
        vazifaniOchir(id);
        fokusla(zaxira, "ochir", null);
      }, () => api.ochir(id));
      break;
    }

bajarildi — yangi qiymat, o'zgarishdan oldin hisoblanadi va serverga aniq qiymat sifatida ketadi: { bajarildi: true }, "teskarisiga o'zgartir" emas. Shunda bir xil so'rov ikki marta borsa ham natija o'zgarmaydi — bunday so'rov idempotent deyiladi. case ichidagi { } — const uchun alohida blok. Qavssiz const butun switch ga tegishli bo'lib qolardi va boshqa case dagi bir xil nom bilan to'qnashardi (Scope asoslari).

Tahrir (tahrirniSaqla oxiri) va import:

js
  serverdaBajar(() => {
    matnniAlmashtir(id, matn);
    fokusla(id, "tahrirla", null);
    elonQil("O'zgarish saqlandi.");
  }, () => api.yangila(id, { matn }));
js
  serverdaBajar(() => {
    royxatniAlmashtir(malumotlar);
    elonQil(`${malumotlar.length} ta vazifa import qilindi.`);
  }, () => api.almashtir(malumotlar));

Importda paketniOqi xatosi avvalgidek try/catch da ushlanadi — u serverga yetib bormaydi. PUT esa importdagi id larni saqlab qoladi.

elonQil chaqiruvlari mahalliy ichida. Nega? Oflaynda mahalliy umuman ishlamaydi va ekranda faqat "Internet yo'q" qoladi. Tashqarida bo'lganda "«Non olish» qo'shildi." ham chiqib, yolg'on gapirardi.

9.3 Sinab ko'ring: ?muvaffaqiyatsiz=1

Avval ilovani oddiy manzil bilan bir marta oching — server ro'yxatni tanisin. Keyin manzilga ?muvaffaqiyatsiz=1 qo'shing. Endi server har o'zgarishni rad etadi:

  • Belgilash: katak darhol belgilanadi, yarim soniyalardan keyin qaytadi; e'londa "Saqlanmadi: Sinov uchun ataylab xato".
  • O'chirish: vazifa yo'qoladi va qaytib keladi; fokus qo'shni «O'chirish» tugmasida qoladi.
  • Qo'shish: yangi vazifa paydo bo'ladi va yo'qoladi.
  • Tahrir: yangi matn ko'rinadi, keyin eski matn qaytadi.

Network panelida har birida qizil 500 va undan keyin GET ni ko'rasiz. Konsolda esa brauzerning o'z yozuvi — Failed to load resource: the server responded with a status of 500 (). Uni brauzer har muvaffaqiyatsiz so'rov uchun yozadi; ilovaning kodi xato bermadi.

Bu xulqni biz Chrome 154 da avtomatik sinov bilan tekshirdik: har to'rt amalda ro'yxat avval o'zgardi, keyin aynan oldingi holatiga qaytdi.

bash
git add assets/js/asosiy.js
git commit -m "Vazifalar: optimistik yangilash va qaytarish"

Tekshirib ko'ring: const oldingi = holat.vazifalar.toJSON(); o'rniga const oldingi = holat.vazifalar; yozilsa, qaytarish ishlaydimi?

Javob

Yo'q. holat.vazifalar — obyektga havola, nusxa emas. mahalliy() aynan shu obyektni o'zgartiradi, demak oldingi ham o'zgargan bo'ladi. Qaytarish o'zgargan ro'yxatning o'zini "tiklaydi" — ekranda hech narsa qaytmaydi. toJSON() esa shu lahzadagi holatdan yangi oddiy massiv yasaydi (Obyekt havolalari).

10. 7-bosqich: oflayn va xato holatlari

Tarmoq holatlari orasidagi yo'llar:

stateDiagram-v2
  [*] --> yuklanmoqda: sahifa ochildi
  yuklanmoqda --> tayyor: server javob berdi
  yuklanmoqda --> xato: server yo'q
  xato --> yuklanmoqda: Qayta urinish
  tayyor --> oflayn: offline
  xato --> oflayn: offline
  oflayn --> yuklanmoqda: online

yuklanmoqda, tayyor va xato ni sinxronla o'zi belgilaydi. Qolgan strelkalarni uchta tinglovchi boshlaydi:

js
qaytaTugma.addEventListener("click", () => sinxronla());

// Internet uzildi yoki qaytdi
addEventListener("offline", () => {
  sinxronPulti?.abort();
  tarmoqniBelgila("oflayn");
});
addEventListener("online", () => sinxronla());

offline va online — window hodisalari: brauzer tarmoqdan uzilganda va qayta ulanganda keladi. addEventListener oldida obyekt yo'q — u global window ning metodi (location va History API darsidagi popstate kabi). Uzilganda yo'ldagi so'rov bekor qilinadi: u baribir javob olmaydi. Internet qaytganda — darhol sinxron.

Oflayn paytida ilova nima qila oladi? Ro'yxat ko'rinadi (keshdan), filtr va eksport ishlaydi. O'zgartirish esa yopiq: serverdaBajar "Internet yo'q — o'zgarish saqlanmaydi." deydi. O'zgarishlarni navbatga yig'ib, internet qaytganda yuborish ham mumkin. Lekin bu ancha murakkab ish: navbat sahifa yopilganda ham saqlanishi, ziddiyatlar hal qilinishi kerak. Uni Offline-first ilova darsida Service Worker bilan quramiz.

Sinash (Network panelidagi "No throttling" ro'yxati):

  • Offline — yuqorida "Internet yo'q. Ro'yxat — shu brauzerdagi nusxa." Katakni bosing — o'zgarmaydi, e'londa sababi.
  • No throttling — xabar yo'qoladi, Network'da GET.
  • Xato holati: DevTools'da Network → so'rovni o'ng tugma bilan bosing → Block request domain. Sahifani yangilang — "Serverga ulanib bo'lmadi…" va «Qayta urinish». Blokni olib tashlab, «Qayta urinish» ni bosing — xabar yo'qoladi.
  • Bekor qilish: "Slow 4G" ni tanlang, F5 bosing va darhol katakni bosing. Birinchi GET qatori (canceled) bo'ladi, belgilash esa serverda saqlanadi.
bash
git add assets/js/asosiy.js
git commit -m "Vazifalar: oflayn va xato holatlari"

11. 8-bosqich: tekshiruv, README va PR

11.1 Node tekshiruvi

Pastki modullar DOM'ga tegmaydi — ularni brauzersiz tekshirish mumkin (vazifalar v2). Faqat bitta to'siq bor: holat.js endi saqlash.js ni import qiladi, localStorage esa Node 24 da yo'q. Buni ko'rish uchun node -e "console.log(typeof localStorage)" buyrug'ini ishga tushiring — undefined chiqadi. Yechim — kichik o'rinbosar, tekshiruv/xotira.js:

js
// tekshiruv/xotira.js — Node'da localStorage o'rinbosari
// Boshqa importlardan OLDIN import qilinadi:
// modul birinchi bajariladi
class XotiraOmbori {
  #yozuvlar = new Map();

  getItem(kalit) {
    return this.#yozuvlar.get(String(kalit)) ?? null;
  }

  setItem(kalit, qiymat) {
    this.#yozuvlar.set(String(kalit), String(qiymat));
  }

  removeItem(kalit) {
    this.#yozuvlar.delete(String(kalit));
  }

  clear() {
    this.#yozuvlar.clear();
  }
}

globalThis.localStorage ??= new XotiraOmbori();

tekshiruv/royxat.js ning birinchi importi — import "./xotira.js";. Modullar import tartibida bajariladi (Modul semantikasi): o'rinbosar holat.js dan oldin tayyor bo'ladi. ??= — "faqat bo'sh bo'lsa": haqiqiy localStorage bor muhitda unga tegmaydi.

Yangi ikki tekshiruv — tekshiruv/saqlash.js va tekshiruv/marshrut.js. Ular «To'liq fayllar» ichida. Hammasini ishga tushiring:

bash
node tekshiruv/royxat.js
node tekshiruv/paket.js
node tekshiruv/saqlash.js
node tekshiruv/marshrut.js
node tekshiruv/api.js

tekshiruv/saqlash.js — Konsolda:

text
✅ bo'sh xotira: null
✅ saqlandi: true
✅ o'qildi: Non olish
✅ bo'sh ro'yxat — null emas: 0
Saqlangan vazifalar buzuq: Matn JSON emas. Eksportni to'liq oling.
✅ buzuq yozuv: null
✅ kalit UUID: true
✅ kalit o'zgarmaydi: true

Belgisiz qator — yukla ning console.warn i: buzuq yozuvni u shunday xabar qiladi. tekshiruv/api.js — API klient darsidagi fayl, PUT va sinov rejimi bilan kengaytirilgan (haqiqiy server, internet kerak). Konsolda:

text
Boshida: 0 ta, versiya 0
PUT: [ 1, 3 ] 1
Qo'shildi: 4 Sabzi olish
Belgilandi: Non olish true
O'chirildi: null
Oxirida: [ 'Non olish', 'Sabzi olish' ] 4
Ataylab: ✅ SERVER 500 Sinov uchun ataylab xato
Yo'q id: ✅ TOPILMADI 404 Vazifa topilmadi
Vaqt: ✅ VAQT 0 Server javob bermadi.
Bekor: ✅ BEKOR 0 So'rov bekor qilindi.

PUT: [ 1, 3 ] — biz yuborgan id lar saqlandi. Qo'shildi: 4 — keyingi id ni server eng kattasidan keyin berdi. royxat.js, paket.js va marshrut.js da hamma qator .

bash
git add tekshiruv/
git commit -m "Vazifalar: tekshiruvni kesh, URL va serverga kengaytir"

11.2 README va PR

Branch'da shunday commitlar to'planishi kerak:

# Commit xabari
1 Docs: v3 ssenariysini yoz
2 Eksport: paket.js ni yana statik import qil
3 API klient: almashtir va sinov rejimi, server kaliti
4 Render: tarmoq holati va fon yangilanishida fokus
5 Vazifalar: avval kesh, keyin server bilan sinxron
6 Vazifalar: optimistik yangilash va qaytarish
7 Vazifalar: oflayn va xato holatlari
8 Vazifalar: tekshiruvni kesh, URL va serverga kengaytir
9 Docs: README ni v3 ga yangila

README.md ning yangi matni va index.html — «To'liq fayllar» ichida. index.html ga api.js uchun modulepreload qo'shiladi (API klient darsida va'da qilingandek; saqlash.js, marshrut.js, sinxron.js uchun qatorlar o'z darslarida qo'shilgan), pastdagi izoh v3 ga yangilanadi. Keyin PR (GitHub CLI):

bash
git push -u origin feature/v3
gh pr create --fill
gh pr view --web
gh pr merge --merge --delete-branch
git switch main
git pull

Bir-ikki daqiqada https://login.github.io/vazifalar/ yangilanadi. Uni oching va 26 qadamli ssenariyni o'tkazing — telefonda ham. Saytdagi ro'yxat Live Server'dagidan alohida: origin boshqa, demak localStorage va kalit ham boshqa (localStorage).

11.3 Biz nimani tekshirdik

Yechimdagi kodni Chrome 154 da (avtomatik sinov bilan) haqiqiy mashq API bilan sinab chiqdik — 39 ta tekshiruv, hammasi o'tdi:

  • Birinchi ochilish: GET → versiya: 0 → PUT, serverda uchta boshlang'ich vazifa; kalit — UUID.
  • Qo'shish, belgilash, tahrir, o'chirish — har biri serverga yetdi; oxirida ekran va server bir xil.
  • Server javobi 1,5 soniya kechiktirilganda: yangilashdan keyin ro'yxat darhol keshdan chiqdi ("Server bilan solishtirilmoqda…"), keyin serverda boshqa joydan qo'shilgan vazifa paydo bo'ldi.
  • Shu kechikishda katak bosildi: yo'ldagi GET bekor qilindi, belgilash yo'qolmadi.
  • Ikki tab: birida qo'shilgan vazifa ikkinchisida darhol chiqdi, o'chirilgani yo'qoldi; alohida sinovda — 16 urinishdan 16 tasida. Tahrir maydonidagi yozilayotgan matn va fokus boshqa tab o'zgarganda joyida qoldi.
  • ?filtr=faol → «Bajarilgan» → «Orqaga» — manzil va tugma qaytdi.
  • ?muvaffaqiyatsiz=1 — to'rt amal ham darhol ko'rindi va qaytdi; e'londa server xabari.
  • Import — PUT, id lar (7 va 9) serverda saqlandi.
  • Oflayn: xabar chiqdi, katak o'zgarmadi; online — sinxron qaytdi.
  • Server kalitni "unutganda" (yangi kalit) — keshdagi ro'yxat serverga yuborildi.
  • Serverga ulanib bo'lmaganda — xato xabari va «Qayta urinish»; u bosilgach — tayyor.

Console'da faqat brauzerning tarmoq yozuvlari bor edi: 500 — sinov rejimi, ERR_CONNECTION_REFUSED — ataylab uzilgan so'rov, 404 — sahifa ikonkasi (favicon.ico) yo'q. Ilova kodidan xato chiqmadi.

12. Hammasi bitta oynada — jonli sinov

Jonli oyna bir nechta faylni ocha olmaydi, shuning uchun o'nta modulni bitta <script type="module"> ga yig'dik: import/export olib tashlangan, tartib — grafik bo'yicha pastdan yuqoriga. Bu faqat sinash uchun; loyihangizda fayllar alohida turadi. Oyna haqiqiy mashq API'ga ulanadi.

Oynaning uchta farqi bor. localStorage — saytning o'rinbosari: ma'lumot shu blok uchun saqlanadi, «Qayta ishga tushirish» "sahifani yangilash" kabi ishlaydi, «Xotirani tozalash» esa keshni ham, kalitni ham o'chiradi. Boshqa tab yo'q, shuning uchun BroadcastChannel jim turadi. Manzil satri ham yo'q — ?filtr= va ?muvaffaqiyatsiz=1 ko'rinmaydi. Qaytarishni ko'rish uchun «Tahrirlab ko'rish» da muvaffaqiyatsiz: qatoridagi ifodani true ga almashtiring.

html
<style>
/* vazifalar.css — vazifalar ro'yxati v3 */
:root {
  --rang-matn: #1f2a24;
  --rang-fon: #f7faf8;
  --rang-asosiy: #1f6f4a;
  --rang-chegara: #c9d6cf;
  --rang-ikkinchi: #5b6660;
  --rang-xato: #b3261e;
}
* { box-sizing: border-box; }
body {
  margin: 0;
  font: 1rem/1.5 system-ui, sans-serif;
  color: var(--rang-matn);
  background: var(--rang-fon);
}
main { max-width: 36rem; margin: 0 auto; padding: 1rem; }
h1 { margin-top: 0; color: var(--rang-asosiy); }
h2 { margin-bottom: 0.25rem; font-size: 1.125rem; }
button, input, textarea { font: inherit; }
button {
  min-height: 2.75rem;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--rang-chegara);
  border-radius: 0.5rem;
  background: #fff;
  color: var(--rang-matn);
}
button[aria-pressed="true"] {
  border-color: var(--rang-asosiy);
  background: var(--rang-asosiy);
  color: #fff;
}
:focus-visible {
  outline: 3px solid var(--rang-asosiy);
  outline-offset: 2px;
}
.qator { display: flex; gap: 0.5rem; }
#yangi-matn, #tahrir-maydon {
  flex: 1;
  min-width: 0;
  min-height: 2.75rem;
  padding: 0.5rem;
  border: 2px solid var(--rang-ikkinchi);
  border-radius: 0.5rem;
}
[aria-invalid="true"] { border-color: var(--rang-xato); }
.xato {
  min-height: 1.5em;
  margin: 0.25rem 0 0;
  color: var(--rang-xato);
}
#filtrlar { display: flex; flex-wrap: wrap; gap: 0.5rem; }
#royxat { margin: 0; padding: 0; list-style: none; }
#royxat li {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  padding-block: 0.5rem;
  border-bottom: 1px solid var(--rang-chegara);
}
#royxat label { flex: 1; min-width: 8rem; overflow-wrap: anywhere; }
#royxat [type="checkbox"] {
  width: 1.25rem;
  height: 1.25rem;
}
.bajarildi label {
  color: var(--rang-ikkinchi);
  text-decoration: line-through;
}
#hisob { font-weight: bold; }
textarea {
  display: block;
  width: 100%;
  margin-block: 0.25rem 0.5rem;
  padding: 0.5rem;
  border: 2px solid var(--rang-ikkinchi);
  border-radius: 0.5rem;
}
#aloqa {
  min-height: 1.5em;
  margin: 0 0 0.5rem;
  color: var(--rang-ikkinchi);
}
</style>
<main>
  <h1>Vazifalar</h1>
  <p id="aloqa" role="status"></p>
  <button type="button" id="qayta-tugma"
          hidden>Qayta urinish</button>
  <form id="yangi-forma" novalidate>
    <label for="yangi-matn">Yangi vazifa</label>
    <div class="qator">
      <input id="yangi-matn" name="matn" required
             maxlength="100" autocomplete="off">
      <button type="submit">Qo'shish</button>
    </div>
    <p id="yangi-matn-xato" class="xato" role="status"></p>
  </form>
  <div id="filtrlar" role="group" aria-label="Filtr">
    <button type="button" data-filtr="hammasi"
            aria-pressed="true">Hammasi</button>
    <button type="button" data-filtr="faol"
            aria-pressed="false">Faol</button>
    <button type="button" data-filtr="bajarilgan"
            aria-pressed="false">Bajarilgan</button>
  </div>
  <h2 id="royxat-sarlavha" tabindex="-1">Ro'yxat</h2>
  <ul id="royxat" aria-labelledby="royxat-sarlavha"></ul>
  <p id="bosh-xabar" hidden></p>
  <p id="hisob" aria-live="polite"></p>
  <p id="teglar" hidden></p>
  <p id="elon" role="status"></p>
  <h2>Eksport</h2>
  <button type="button"
          id="eksport-tugma">JSON qilib chiqarish</button>
  <label for="eksport-matn">Vazifalar JSON ko'rinishida</label>
  <textarea id="eksport-matn" rows="6" readonly></textarea>
  <h2>Import</h2>
  <label for="import-matn">Eksport qilingan JSON matni</label>
  <textarea id="import-matn" rows="6"></textarea>
  <button type="button" id="import-tugma">Import qilish</button>
  <p id="import-xato" class="xato" aria-live="polite"></p>
  <p><small>v3: ro'yxat shu brauzerda va «Bahor» mashq
    serverida saqlanadi. Server — o'rganish uchun: u qayta
    ishga tushsa, ro'yxat shu brauzerdagi nusxadan
    tiklanadi.</small></p>
</main>
<script type="module">
// vazifalar v3 — o'nta modul bitta oynada (faqat jonli namuna)

// vazifa.js — bitta vazifa va vazifalar xatosi

class VazifaXatosi extends Error {
  constructor(xabar, sozlama) {
    super(xabar, sozlama);
    this.name = "VazifaXatosi";
    this.kod = sozlama?.kod ?? "NOMALUM";
  }
}

class Vazifa {
  // index.html dagi maxlength bilan bir xil bo'lsin
  static MAKS_UZUNLIK = 100;

  id;
  bajarildi;
  #matn;

  constructor(id, matn, bajarildi = false) {
    this.id = id;
    this.#matn = matn;
    this.bajarildi = bajarildi;
  }

  static dan(malumot) {
    return new Vazifa(malumot.id, malumot.matn, malumot.bajarildi);
  }

  get matn() {
    return this.#matn;
  }

  matnniOzgartir(yangi) {
    const toza = yangi.trim();
    if (toza === "" || toza.length > Vazifa.MAKS_UZUNLIK) {
      return false;
    }
    this.#matn = toza;
    return true;
  }

  almashtir() {
    this.bajarildi = !this.bajarildi;
  }

  toJSON() {
    return {
      id: this.id,
      matn: this.#matn,
      bajarildi: this.bajarildi,
    };
  }
}

// royxat.js — vazifalar to'plami: so'rovlar, o'zgartirishlar, obuna

// Quyidagi beshtasi eksport qilinmaydi — faqat shu modul ichida
function idYasovchiYarat(boshlangich) {
  let keyingi = boshlangich;
  return () => keyingi++;
}

function faollarOldin(a, b) {
  return Number(a.bajarildi) - Number(b.bajarildi);
}

function guruhNomi(vazifa) {
  return vazifa.bajarildi ? "bajarilgan" : "faol";
}

function teglariniOl(matn) {
  return new Set(
    matn
      .toLowerCase()
      .split(" ")
      .filter((soz) => soz.length > 1 && soz.startsWith("#")),
  );
}

function tegToplami(vazifalar) {
  return new Set(
    vazifalar.flatMap((vazifa) => [...teglariniOl(vazifa.matn)]),
  );
}

class VazifalarRoyxati {
  #vazifalar = [];
  #yangiId = idYasovchiYarat(1);
  #tinglovchilar = [];

  static dan(malumotlar) {
    const yangi = new VazifalarRoyxati();
    yangi.#vazifalar = malumotlar.map((m) => Vazifa.dan(m));
    const engKatta = malumotlar.reduce(
      (eng, m) => Math.max(eng, m.id),
      0,
    );
    yangi.#yangiId = idYasovchiYarat(engKatta + 1);
    return yangi;
  }

  static mosmi(vazifa, filtr) {
    if (filtr === "faol") {
      return !vazifa.bajarildi;
    }
    if (filtr === "bajarilgan") {
      return vazifa.bajarildi;
    }
    return true;
  }

  // So'rovlar: royxatni o'zgartirmaydi
  statistika() {
    const guruhlar = Object.groupBy(this.#vazifalar, guruhNomi);
    return {
      qoldi: guruhlar.faol?.length ?? 0,
      bajarildi: guruhlar.bajarilgan?.length ?? 0,
    };
  }

  get qoldi() {
    return this.statistika().qoldi;
  }

  korinadiganlar(filtr) {
    return this.#vazifalar
      .filter((v) => VazifalarRoyxati.mosmi(v, filtr))
      .toSorted(faollarOldin);
  }

  top(id) {
    return this.#vazifalar.find((v) => v.id === id);
  }

  matnBormi(matn, ozId) {
    const kichik = matn.toLowerCase();
    return this.#vazifalar.some(
      (v) => v.id !== ozId && v.matn.toLowerCase() === kichik,
    );
  }

  tegHisobi() {
    return this.#vazifalar
      .flatMap((vazifa) => [...teglariniOl(vazifa.matn)])
      .reduce(
        (sanoq, teg) => sanoq.set(teg, (sanoq.get(teg) ?? 0) + 1),
        new Map(),
      );
  }

  yopilganTeglar() {
    const bajarilgan = this.#vazifalar.filter((v) => v.bajarildi);
    const faol = this.#vazifalar.filter((v) => !v.bajarildi);
    return tegToplami(bajarilgan).difference(tegToplami(faol));
  }

  // Kuzatuvchi: royxat o'zgarsa, obunachilarga xabar
  obuna(tinglovchi) {
    this.#tinglovchilar.push(tinglovchi);
    return () => {
      this.#tinglovchilar = this.#tinglovchilar.filter(
        (t) => t !== tinglovchi,
      );
    };
  }

  #xabarBer() {
    this.#tinglovchilar.forEach((tinglovchi) => tinglovchi());
  }

  // O'zgartiruvchilar: har biri oxirida #xabarBer()
  qosh(matn) {
    const vazifa = new Vazifa(this.#yangiId(), matn);
    this.#vazifalar.push(vazifa);
    this.#xabarBer();
    return vazifa;
  }

  almashtir(id) {
    this.top(id).almashtir();
    this.#xabarBer();
  }

  matnniOzgartir(id, matn) {
    const ozgardi = this.top(id).matnniOzgartir(matn);
    this.#xabarBer();
    return ozgardi;
  }

  ochir(id) {
    this.#vazifalar = this.#vazifalar.filter((v) => v.id !== id);
    this.#xabarBer();
  }

  *[Symbol.iterator]() {
    yield* this.#vazifalar;
  }

  toJSON() {
    return this.#vazifalar.map((v) => v.toJSON());
  }
}

// paket.js — eksport va import: {versiya: 1, vazifalar: [...]}

const VAZIFA_MAYDONLARI = ["id", "matn", "bajarildi"];
const eksportMaydonlari = [
  "versiya", "vazifalar", ...VAZIFA_MAYDONLARI,
];

function eksportMatni(vazifalar) {
  const paket = { versiya: 1, vazifalar };
  return JSON.stringify(paket, eksportMaydonlari, 2);
}

function oddiyObyektmi(qiymat) {
  return typeof qiymat === "object" && qiymat !== null
    && !Array.isArray(qiymat);
}

// Oq ro'yxat: faqat uch maydon va faqat o'z kalitlari
function vazifaniOl(xom) {
  const vazifa = {};
  if (!oddiyObyektmi(xom)) {
    return vazifa;
  }
  for (const maydon of VAZIFA_MAYDONLARI) {
    if (Object.hasOwn(xom, maydon)) {
      vazifa[maydon] = xom[maydon];
    }
  }
  return vazifa;
}

function vazifaXatosi(tartib, sabab) {
  return new VazifaXatosi(`${tartib}-vazifa: ${sabab}`, {
    kod: "VAZIFA",
  });
}

function malumotlarniTekshir(malumotlar) {
  const idlar = new Set();
  malumotlar.forEach((v, i) => {
    const tartib = i + 1;
    if (!Number.isInteger(v.id) || v.id < 1 || idlar.has(v.id)) {
      throw vazifaXatosi(tartib, "id musbat, takrorlanmas son emas.");
    }
    idlar.add(v.id);
    if (typeof v.matn !== "string" || v.matn.trim() === "") {
      throw vazifaXatosi(tartib, "matn bo'sh.");
    }
    if (v.matn.length > Vazifa.MAKS_UZUNLIK) {
      throw vazifaXatosi(tartib, "matn juda uzun.");
    }
    if (typeof v.bajarildi !== "boolean") {
      throw vazifaXatosi(tartib, "bajarildi true yoki false emas.");
    }
  });
}

function paketniOqi(matn) {
  let xom;
  try {
    xom = JSON.parse(matn);
  } catch (xato) {
    throw new VazifaXatosi(
      "Matn JSON emas. Eksportni to'liq oling.",
      { kod: "JSON", cause: xato },
    );
  }
  if (!oddiyObyektmi(xom)) {
    throw new VazifaXatosi("Bu matn vazifalar eksporti emas.", {
      kod: "TUZILMA",
    });
  }
  if (xom.versiya !== 1) {
    throw new VazifaXatosi(
      `Versiya ${xom.versiya} o'qilmaydi — faqat 1.`,
      { kod: "VERSIYA" },
    );
  }
  if (!Array.isArray(xom.vazifalar)) {
    throw new VazifaXatosi("Matnda vazifalar ro'yxati yo'q.", {
      kod: "TUZILMA",
    });
  }
  const malumotlar = xom.vazifalar.map(vazifaniOl);
  malumotlarniTekshir(malumotlar);
  return malumotlar;
}

// saqlash.js — brauzer xotirasi: vazifalar keshi va server kaliti

const SAQLASH_KALITI = "vazifalar:v3";
const KALIT_NOMI = "vazifalar:kalit";

function saqla(royxat) {
  try {
    localStorage.setItem(SAQLASH_KALITI, eksportMatni(royxat));
    return true;
  } catch (xato) {
    console.warn(`Vazifalar saqlanmadi: ${xato.name}`);
    return false;
  }
}

function yukla() {
  let matn;
  try {
    matn = localStorage.getItem(SAQLASH_KALITI);
  } catch (xato) {
    console.warn(`Xotirani o'qib bo'lmadi: ${xato.name}`);
    return null;
  }
  if (matn === null) {
    return null;
  }
  try {
    return paketniOqi(matn);
  } catch (xato) {
    console.warn(`Saqlangan vazifalar buzuq: ${xato.message}`);
    return null;
  }
}

// Shu brauzerning serverdagi ro'yxati: kalit bir marta yaratiladi
function kalitniOl() {
  try {
    let kalit = localStorage.getItem(KALIT_NOMI);
    if (kalit === null) {
      kalit = crypto.randomUUID();
      localStorage.setItem(KALIT_NOMI, kalit);
    }
    return kalit;
  } catch (xato) {
    console.warn(`Kalit saqlanmadi: ${xato.name}`);
    return crypto.randomUUID();
  }
}

// holat.js — ilova holati: yagona haqiqat manbai

function chuqurMuzlat(obyekt) {
  for (const qiymat of Object.values(obyekt)) {
    if (typeof qiymat === "object" && qiymat !== null) {
      chuqurMuzlat(qiymat);
    }
  }
  return Object.freeze(obyekt);
}

const boshlangichVazifalar = chuqurMuzlat([
  { id: 1, matn: "Non olish", bajarildi: false },
  { id: 2, matn: "DOM darsini takrorlash", bajarildi: true },
  { id: 3, matn: "Onamga qo'ng'iroq qilish", bajarildi: false },
]);

// Ro'yxat keshdan darhol olinadi; server bilan keyin solishtiriladi
const holat = {
  vazifalar: VazifalarRoyxati.dan(yukla() ?? boshlangichVazifalar),
  filtr: "hammasi",
  tahrirId: null,
  tarmoq: "yuklanmoqda", // "tayyor" | "xato" | "oflayn"
};

// So'rovlar: faqat qiymat qaytaradi
function xatoniTop(matn, vazifalar, ozId) {
  if (matn === "") {
    return "Vazifa matnini yozing.";
  }
  if (vazifalar.matnBormi(matn, ozId)) {
    return "Bu vazifa ro'yxatda allaqachon bor.";
  }
  return null;
}

function korinadiganlar() {
  return holat.vazifalar.korinadiganlar(holat.filtr);
}

function vazifaniTop(id) {
  return holat.vazifalar.top(id);
}

// O'zgartiruvchilar: avval holat, oxirida royxat (u render'ni
// kuzatuvchi orqali chaqiradi)
function vazifaQosh(matn) {
  if (holat.filtr === "bajarilgan") {
    holat.filtr = "hammasi";
  }
  holat.vazifalar.qosh(matn);
}

function vazifaniAlmashtir(id) {
  holat.vazifalar.almashtir(id);
}

function vazifaniOchir(id) {
  holat.vazifalar.ochir(id);
}

function matnniAlmashtir(id, matn) {
  holat.tahrirId = null;
  holat.vazifalar.matnniOzgartir(id, matn);
}

// marshrut.js — filtr holati URL'da: ?filtr=faol
const FILTRLAR = ["hammasi", "faol", "bajarilgan"];

function filtrniOl(manzil = location.href) {
  const filtr = new URL(manzil).searchParams.get("filtr");
  return FILTRLAR.includes(filtr) ? filtr : "hammasi";
}

function filtrniYoz(filtr) {
  const url = new URL(location.href);
  if (filtr === "hammasi") {
    url.searchParams.delete("filtr");
  } else {
    url.searchParams.set("filtr", filtr);
  }
  if (url.href !== location.href) {
    history.pushState(null, "", url);
  }
}

// sinxron.js — bir saytning tablari orasida xabar almashish
const kanal = new BroadcastChannel("vazifalar");

function tarqat(xabar) {
  kanal.postMessage(xabar);
}

function tingla(fn) {
  kanal.addEventListener("message", (event) => fn(event.data));
}

// api.js — server bilan gaplashadigan yagona joy
const KUTISH_MS = 8000;

class ApiXatosi extends Error {
  constructor(xabar, sozlama) {
    super(xabar, sozlama);
    this.name = "ApiXatosi";
    this.holat = sozlama?.holat ?? 0;
    this.kod = sozlama?.kod ?? "NOMALUM";
  }
}

function holatKodi(holat) {
  if (holat === 404) return "TOPILMADI";
  if (holat === 400 || holat === 422) return "NOTOGRI";
  if (holat >= 500) return "SERVER";
  return "HTTP";
}

function tarmoqXatosi(xato) {
  if (xato.name === "TimeoutError") {
    return new ApiXatosi("Server javob bermadi.", {
      kod: "VAQT", cause: xato,
    });
  }
  if (xato.name === "AbortError") {
    return new ApiXatosi("So'rov bekor qilindi.", {
      kod: "BEKOR", cause: xato,
    });
  }
  return new ApiXatosi("Internet aloqasini tekshiring.", {
    kod: "TARMOQ", cause: xato,
  });
}

async function sorovYubor(url, sozlama = {}) {
  const { metod = "GET", tana, signal, kutish = KUTISH_MS } = sozlama;
  const vaqt = AbortSignal.timeout(kutish);
  const sorov = {
    method: metod,
    headers: { Accept: "application/json" },
    signal: signal ? AbortSignal.any([signal, vaqt]) : vaqt,
  };
  if (tana !== undefined) {
    sorov.headers["Content-Type"] = "application/json";
    sorov.body = JSON.stringify(tana);
  }

  let javob;
  let matn;
  try {
    javob = await fetch(url, sorov);
    matn = await javob.text();
  } catch (xato) {
    throw tarmoqXatosi(xato);
  }

  let malumot = null;
  if (matn !== "") {
    try {
      malumot = JSON.parse(matn);
    } catch (xato) {
      throw new ApiXatosi("Server JSON qaytarmadi.", {
        holat: javob.status, kod: "JAVOB", cause: xato,
      });
    }
  }
  if (!javob.ok) {
    const xabar = malumot?.xato ?? `Server xatosi: ${javob.status}`;
    throw new ApiXatosi(xabar, {
      holat: javob.status, kod: holatKodi(javob.status),
    });
  }
  return malumot;
}

function apiKlient({
  asosiyUrl, kalit, muvaffaqiyatsiz = false,
}) {
  const manzil = `${asosiyUrl}/vazifalar/${kalit}`;
  // Sinov rejimi: server har yozuvga ataylab 500 qaytaradi
  const oxiri = muvaffaqiyatsiz ? "?muvaffaqiyatsiz=1" : "";
  return {
    royxat: (sozlama) => sorovYubor(manzil, sozlama),
    qosh: (matn, sozlama) => sorovYubor(manzil + oxiri, {
      ...sozlama, metod: "POST", tana: { matn },
    }),
    yangila: (id, ozgarish, sozlama) =>
      sorovYubor(`${manzil}/${id}${oxiri}`, {
        ...sozlama, metod: "PATCH", tana: ozgarish,
      }),
    ochir: (id, sozlama) => sorovYubor(`${manzil}/${id}${oxiri}`, {
      ...sozlama, metod: "DELETE",
    }),
    almashtir: (vazifalar, sozlama) => sorovYubor(manzil + oxiri, {
      ...sozlama, metod: "PUT", tana: { vazifalar },
    }),
  };
}

// render.js — holatdan ekranga: chizish, fokus, e'lon

const boshMatnlar = Object.freeze({
  hammasi: "Hozircha vazifa yo'q. Birinchisini qo'shing!",
  faol: "Faol vazifa yo'q.",
  bajarilgan: "Bajarilgan vazifa hali yo'q.",
});

// Sahifa elementlari
const forma = document.getElementById("yangi-forma");
const maydon = document.getElementById("yangi-matn");
const maydonXato = document.getElementById("yangi-matn-xato");
const filtrlar = document.getElementById("filtrlar");
const sarlavha = document.getElementById("royxat-sarlavha");
const royxat = document.getElementById("royxat");
const boshXabar = document.getElementById("bosh-xabar");
const hisob = document.getElementById("hisob");
const teglarQatori = document.getElementById("teglar");
const elon = document.getElementById("elon");
const eksportTugma = document.getElementById("eksport-tugma");
const eksportMatn = document.getElementById("eksport-matn");
const importMatn = document.getElementById("import-matn");
const importTugma = document.getElementById("import-tugma");
const importXato = document.getElementById("import-xato");
const aloqa = document.getElementById("aloqa");
const qaytaTugma = document.getElementById("qayta-tugma");

const aloqaMatnlari = Object.freeze({
  yuklanmoqda: "Server bilan solishtirilmoqda…",
  tayyor: "",
  xato: "Serverga ulanib bo'lmadi. Ro'yxat — shu brauzerdagi nusxa.",
  oflayn: "Internet yo'q. Ro'yxat — shu brauzerdagi nusxa.",
});

function tugmaYasa(amal, matn, nomi) {
  const tugma = document.createElement("button");
  tugma.type = "button";
  tugma.dataset.amal = amal;
  tugma.textContent = matn;
  tugma.setAttribute("aria-label", `${matn}: ${nomi}`);
  return tugma;
}

// Amal nomi va tugma matni — bir joyda, juftlik bilan
const tahrirTugmasi = tugmaYasa.bind(null, "tahrirla", "Tahrirlash");
const ochirTugmasi = tugmaYasa.bind(null, "ochir", "O'chirish");
const saqlaTugmasi = tugmaYasa.bind(null, "saqla", "Saqlash");
const bekorTugmasi = tugmaYasa.bind(null, "bekor", "Bekor qilish");

function oddiyQator(vazifa) {
  const li = document.createElement("li");
  li.dataset.id = vazifa.id;
  li.classList.toggle("bajarildi", vazifa.bajarildi);
  const katak = document.createElement("input");
  katak.type = "checkbox";
  katak.id = `vazifa-${vazifa.id}`;
  katak.dataset.amal = "almashtir";
  katak.checked = vazifa.bajarildi;
  const yorliq = document.createElement("label");
  yorliq.htmlFor = katak.id;
  yorliq.textContent = vazifa.matn;
  li.append(
    katak,
    yorliq,
    tahrirTugmasi(vazifa.matn),
    ochirTugmasi(vazifa.matn),
  );
  return li;
}

function tahrirQatori(vazifa) {
  const li = document.createElement("li");
  li.dataset.id = vazifa.id;
  const kiritma = document.createElement("input");
  kiritma.id = "tahrir-maydon";
  kiritma.value = vazifa.matn;
  kiritma.maxLength = Vazifa.MAKS_UZUNLIK;
  kiritma.setAttribute("aria-label", "Vazifa matni");
  li.append(
    kiritma,
    saqlaTugmasi(vazifa.matn),
    bekorTugmasi(vazifa.matn),
  );
  return li;
}

function qatorYasa(vazifa) {
  if (vazifa.id === holat.tahrirId) {
    return tahrirQatori(vazifa);
  }
  return oddiyQator(vazifa);
}

function matnniYoz(element, matn) {
  if (element.textContent !== matn) {
    element.textContent = matn;
  }
}

function teglarMatni(sanoq) {
  return [...sanoq]
    .map(([teg, soni]) => `${teg} — ${soni}`)
    .join(", ");
}

function teglarniYoz() {
  const sanoq = holat.vazifalar.tegHisobi();
  const yopilgan = [...holat.vazifalar.yopilganTeglar()];
  const qoshimcha =
    yopilgan.length > 0 ? `. Yopilgan: ${yopilgan.join(", ")}` : "";
  teglarQatori.hidden = sanoq.size === 0;
  matnniYoz(
    teglarQatori,
    `Teglar: ${teglarMatni(sanoq)}${qoshimcha}`,
  );
}

function render() {
  royxat.replaceChildren(...korinadiganlar().map(qatorYasa));

  boshXabar.hidden = royxat.children.length > 0;
  matnniYoz(boshXabar, boshMatnlar[holat.filtr]);
  filtrlar.querySelectorAll("[data-filtr]").forEach((tugma) => {
    const tanlangan = tugma.dataset.filtr === holat.filtr;
    tugma.setAttribute("aria-pressed", String(tanlangan));
  });
  const { qoldi, bajarildi } = holat.vazifalar.statistika();
  matnniYoz(
    hisob,
    `${qoldi} ta vazifa qoldi, ${bajarildi} ta bajarildi`,
  );
  teglarniYoz();
  aloqaniYoz();
}

// Tarmoq holati: faqat yuqoridagi qator, ro'yxatga tegmaydi
function aloqaniYoz() {
  matnniYoz(aloqa, aloqaMatnlari[holat.tarmoq]);
  qaytaTugma.hidden = holat.tarmoq !== "xato";
}

// Fon yangilanishi (server, boshqa tab, qaytarish): fokus va
// tahrirdagi matn joyida qoladi
function fondaChiz() {
  const faol = document.activeElement;
  if (!royxat.contains(faol)) {
    render();
    return;
  }
  const id = Number(faol.closest("li").dataset.id);
  const amal = faol.dataset.amal;
  const qoralama = faol.id === "tahrir-maydon" ? faol.value : null;
  render();
  const kiritma = document.getElementById("tahrir-maydon");
  if (qoralama !== null && kiritma !== null) {
    kiritma.value = qoralama;
    kiritma.focus();
    return;
  }
  fokusla(id, amal, null);
}

// Fokus va e'lon: render'dan keyin
function fokusla(id, amal, zaxiraId) {
  // querySelector — royxat'ning metodi: uni nuqta bilan chaqiramiz
  const top = (i) =>
    royxat.querySelector(`[data-id="${i}"] [data-amal="${amal}"]`);
  const nishon = top(id) ?? top(zaxiraId) ?? sarlavha;
  nishon.focus();
}

function qoshniId(id) {
  const qator = royxat.querySelector(`[data-id="${id}"]`);
  const qoshni =
    qator.nextElementSibling ?? qator.previousElementSibling;
  return qoshni === null ? null : Number(qoshni.dataset.id);
}

const ELON_MUDDATI = 3000;
let elonTaymeri = null;

function elonQil(matn) {
  clearTimeout(elonTaymeri);
  elon.textContent = matn;
  if (matn === "") {
    return;
  }
  elonTaymeri = setTimeout(() => {
    elon.textContent = "";
  }, ELON_MUDDATI);
}

function xatoniKorsat(xabar) {
  maydonXato.textContent = xabar;
  maydon.setAttribute("aria-invalid", "true");
  maydon.setAttribute("aria-describedby", maydonXato.id);
}

function xatoniTozala() {
  maydonXato.textContent = "";
  maydon.removeAttribute("aria-invalid");
  maydon.removeAttribute("aria-describedby");
}

function tahrirgaOt() {
  const kiritma = document.getElementById("tahrir-maydon");
  kiritma.focus();
  kiritma.select();
}

function importXatosiniKorsat(xabar) {
  importXato.textContent = xabar;
  importMatn.setAttribute("aria-invalid", "true");
  importMatn.focus();
}

function importXatosiniTozala() {
  importXato.textContent = "";
  importMatn.removeAttribute("aria-invalid");
}

// asosiy.js — kirish nuqtasi: hodisalar, server bilan sinxron

const sahifa = new URL(location.href);
const api = apiKlient({
  asosiyUrl: "https://ilmhamroh.uz/api/mashq",
  kalit: kalitniOl(),
  muvaffaqiyatsiz: sahifa.searchParams.get("muvaffaqiyatsiz") === "1",
});

// Holat + chizish: royxatga tegmaydigan o'zgarishlar
function tahrirniBoshla(id) {
  holat.tahrirId = id;
  render();
}

function tahrirniYop() {
  holat.tahrirId = null;
  render();
}

function filtrniTanla(filtr) {
  // Faqat boshMatnlar ning O'Z kalitlari — "constructor" filtr emas
  if (!Object.hasOwn(boshMatnlar, filtr)) {
    return;
  }
  holat.filtr = filtr;
  holat.tahrirId = null;
  filtrniYoz(filtr);
  render();
}

// Xotiraga va boshqa tablarga: ro'yxat xabarning o'zida ketadi
function saqlaVaTarqat() {
  saqla(holat.vazifalar);
  tarqat({ tur: "ozgardi", paket: eksportMatni(holat.vazifalar) });
}

// Ro'yxat o'zgarsa: xotiraga, boshqa tablarga, ekranga
function saqlaVaChiz() {
  saqlaVaTarqat();
  render();
}

// Yangi ro'yxat: obuna qayta ulanadi, filtr joyida qoladi
function royxatniUla(malumotlar) {
  holat.vazifalar = VazifalarRoyxati.dan(malumotlar);
  holat.vazifalar.obuna(saqlaVaChiz);
  const tahrirdagi = holat.tahrirId;
  if (tahrirdagi !== null && !holat.vazifalar.top(tahrirdagi)) {
    holat.tahrirId = null;
  }
}

// Import: ro'yxat almashadi, filtr "hammasi" ga qaytadi
function royxatniAlmashtir(malumotlar) {
  royxatniUla(malumotlar);
  holat.filtr = "hammasi";
  holat.tahrirId = null;
  filtrniYoz("hammasi");
  saqlaVaChiz();
}

// Server javobi yoki qaytarish: saqlab, fokusni joyida qoldiramiz
function royxatniYangila(malumotlar) {
  royxatniUla(malumotlar);
  saqlaVaTarqat();
  fondaChiz();
}

// Boshqa tab o'zgartirdi: ro'yxat xabarda keldi (saqlamaymiz!)
function boshqaTabdanYangila(xabar) {
  if (xabar?.tur !== "ozgardi") {
    return;
  }
  let malumotlar;
  try {
    malumotlar = paketniOqi(xabar.paket);
  } catch (xato) {
    console.warn(`Boshqa tab xabari o'qilmadi: ${xato.message}`);
    return;
  }
  royxatniUla(malumotlar);
  fondaChiz();
}

// Server bilan sinxron
let sinxronPulti = null;
let kutilmoqda = 0;

function tarmoqniBelgila(tarmoq) {
  holat.tarmoq = tarmoq;
  aloqaniYoz();
}

async function sinxronla() {
  if (kutilmoqda > 0) {
    return; // yozuvlar tugagach baribir chaqiriladi
  }
  sinxronPulti?.abort(); // eski so'rov endi kerak emas
  if (!navigator.onLine) {
    tarmoqniBelgila("oflayn");
    return;
  }
  const pult = new AbortController();
  sinxronPulti = pult;
  if (holat.tarmoq !== "tayyor") {
    tarmoqniBelgila("yuklanmoqda");
  }
  try {
    const sozlama = { signal: pult.signal };
    let { vazifalar, versiya } = await api.royxat(sozlama);
    if (versiya === 0) {
      // Server bu kalitni bilmaydi: ro'yxatni o'zimizdan yuboramiz
      const joriy = holat.vazifalar.toJSON();
      ({ vazifalar } = await api.almashtir(joriy, sozlama));
    }
    // Server javobi ham import kabi tekshiriladi
    const toza = paketniOqi(eksportMatni(vazifalar));
    if (eksportMatni(toza) !== eksportMatni(holat.vazifalar)) {
      royxatniYangila(toza);
    }
    tarmoqniBelgila("tayyor");
  } catch (xato) {
    if (xato.kod === "BEKOR") {
      return; // o'rniga yangi so'rov ketdi
    }
    console.warn(`Sinxron bo'lmadi: ${xato.message}`);
    tarmoqniBelgila(navigator.onLine ? "xato" : "oflayn");
  } finally {
    if (sinxronPulti === pult) {
      sinxronPulti = null;
    }
  }
}

// Optimistik yangilash: avval ekranda, keyin serverda;
// server rad etsa — oldingi holatga qaytaramiz
async function serverdaBajar(mahalliy, serverda) {
  if (holat.tarmoq === "oflayn") {
    fondaChiz(); // bosilgan katak ham eski holatiga qaytsin
    elonQil("Internet yo'q — o'zgarish saqlanmaydi.");
    return;
  }
  sinxronPulti?.abort(); // yo'ldagi ro'yxat endi eskirgan
  const oldingi = holat.vazifalar.toJSON();
  mahalliy();
  kutilmoqda++;
  try {
    await serverda();
  } catch (xato) {
    royxatniYangila(oldingi);
    elonQil(`Saqlanmadi: ${xato.message}`);
  } finally {
    kutilmoqda--;
    sinxronla(); // oxirgisi tugagach — server bilan solishtirish
  }
}

function tahrirniSaqla() {
  const kiritma = document.getElementById("tahrir-maydon");
  const id = holat.tahrirId;
  const matn = kiritma.value.trim();
  const xato = xatoniTop(matn, holat.vazifalar, id);
  if (xato !== null) {
    kiritma.setAttribute("aria-invalid", "true");
    kiritma.focus();
    elonQil(xato);
    return;
  }
  serverdaBajar(() => {
    matnniAlmashtir(id, matn);
    fokusla(id, "tahrirla", null);
    elonQil("O'zgarish saqlandi.");
  }, () => api.yangila(id, { matn }));
}

function tahrirniBekorQil() {
  const id = holat.tahrirId;
  tahrirniYop();
  fokusla(id, "tahrirla", null);
}

// Hodisalar: har joyda bitta tinglovchi
forma.addEventListener("submit", (event) => {
  event.preventDefault();
  elonQil("");
  const matn = maydon.value.trim();
  const xato = xatoniTop(matn, holat.vazifalar, null);
  if (xato !== null) {
    xatoniKorsat(xato);
    maydon.focus();
    return;
  }
  xatoniTozala();
  serverdaBajar(() => {
    vazifaQosh(matn);
    filtrniYoz(holat.filtr);
    forma.reset();
    elonQil(`«${matn}» qo'shildi.`);
  }, () => api.qosh(matn));
});

maydon.addEventListener("input", () => {
  if (maydon.getAttribute("aria-invalid") !== "true") {
    return;
  }
  const matn = maydon.value.trim();
  if (xatoniTop(matn, holat.vazifalar, null) === null) {
    xatoniTozala();
  }
});

royxat.addEventListener("click", (event) => {
  const nishon = event.target.closest("[data-amal]");
  if (nishon === null || !royxat.contains(nishon)) {
    return;
  }
  elonQil("");
  const amal = nishon.dataset.amal;
  const id = Number(nishon.closest("li").dataset.id);
  const zaxira = qoshniId(id);
  if (amal !== "saqla" && amal !== "bekor") {
    holat.tahrirId = null;
  }
  switch (amal) {
    case "almashtir": {
      const bajarildi = !vazifaniTop(id).bajarildi;
      serverdaBajar(() => {
        vazifaniAlmashtir(id);
        fokusla(id, "almashtir", zaxira);
      }, () => api.yangila(id, { bajarildi }));
      break;
    }
    case "ochir": {
      const { matn } = vazifaniTop(id);
      serverdaBajar(() => {
        elonQil(`«${matn}» o'chirildi.`);
        vazifaniOchir(id);
        fokusla(zaxira, "ochir", null);
      }, () => api.ochir(id));
      break;
    }
    case "tahrirla":
      tahrirniBoshla(id);
      tahrirgaOt();
      break;
    case "saqla":
      tahrirniSaqla();
      break;
    case "bekor":
      tahrirniBekorQil();
      break;
  }
});

royxat.addEventListener("keydown", (event) => {
  if (event.target.id !== "tahrir-maydon" || event.isComposing) {
    return;
  }
  if (event.key === "Enter") {
    event.preventDefault();
    tahrirniSaqla();
  } else if (event.key === "Escape") {
    tahrirniBekorQil();
  }
});

filtrlar.addEventListener("click", (event) => {
  const tugma = event.target.closest("[data-filtr]");
  if (tugma === null) {
    return;
  }
  elonQil("");
  filtrniTanla(tugma.dataset.filtr);
});

eksportTugma.addEventListener("click", () => {
  eksportMatn.value = eksportMatni(holat.vazifalar);
  eksportMatn.focus();
  eksportMatn.select();
  elonQil("JSON tayyor. Nusxa olish uchun Ctrl+C bosing.");
});

importTugma.addEventListener("click", () => {
  importXatosiniTozala();
  elonQil("");
  let malumotlar;
  try {
    malumotlar = paketniOqi(importMatn.value);
  } catch (xato) {
    if (xato instanceof VazifaXatosi) {
      importXatosiniKorsat(xato.message);
      return;
    }
    importXatosiniKorsat("Kutilmagan xato: import bajarilmadi.");
    throw xato;
  }
  serverdaBajar(() => {
    royxatniAlmashtir(malumotlar);
    elonQil(`${malumotlar.length} ta vazifa import qilindi.`);
  }, () => api.almashtir(malumotlar));
});

qaytaTugma.addEventListener("click", () => sinxronla());

// Orqaga/Oldinga: filtr URL'dan qayta o'qiladi
addEventListener("popstate", () => {
  holat.filtr = filtrniOl();
  holat.tahrirId = null;
  render();
});

// Internet uzildi yoki qaytdi
addEventListener("offline", () => {
  sinxronPulti?.abort();
  tarmoqniBelgila("oflayn");
});
addEventListener("online", () => sinxronla());

// Boshqa tablardagi o'zgarishlar
tingla(boshqaTabdanYangila);

// Ishga tushirish: avval keshdan darhol, keyin server
holat.filtr = filtrniOl();
holat.vazifalar.obuna(saqlaVaChiz);
render();
sinxronla();
</script>

Oynada sinab ko'ring: vazifa qo'shing, belgilang, «Qayta ishga tushirish» ni bosing — ro'yxat joyida, chunki u keshdan, keyin serverdan keldi. Konsol bo'sh bo'lishi kerak.

To'liq fayllar: asosiy.js, render.js, holat.js, saqlash.js, api.js

assets/js/asosiy.js:

js
// asosiy.js — kirish nuqtasi: hodisalar, server bilan sinxron
import { VazifaXatosi } from "./vazifa.js";
import { VazifalarRoyxati } from "./royxat.js";
import { eksportMatni, paketniOqi } from "./paket.js";
import { saqla, kalitniOl } from "./saqlash.js";
import { filtrniOl, filtrniYoz } from "./marshrut.js";
import { tarqat, tingla } from "./sinxron.js";
import { apiKlient } from "./api.js";
import {
  holat,
  xatoniTop,
  vazifaniTop,
  vazifaQosh,
  vazifaniAlmashtir,
  vazifaniOchir,
  matnniAlmashtir,
} from "./holat.js";
import {
  boshMatnlar,
  forma,
  maydon,
  royxat,
  filtrlar,
  eksportTugma,
  eksportMatn,
  importMatn,
  importTugma,
  qaytaTugma,
  render,
  aloqaniYoz,
  fondaChiz,
  fokusla,
  qoshniId,
  elonQil,
  xatoniKorsat,
  xatoniTozala,
  tahrirgaOt,
  importXatosiniKorsat,
  importXatosiniTozala,
} from "./render.js";

const sahifa = new URL(location.href);
const api = apiKlient({
  asosiyUrl: "https://ilmhamroh.uz/api/mashq",
  kalit: kalitniOl(),
  muvaffaqiyatsiz: sahifa.searchParams.get("muvaffaqiyatsiz") === "1",
});

// Holat + chizish: royxatga tegmaydigan o'zgarishlar
function tahrirniBoshla(id) {
  holat.tahrirId = id;
  render();
}

function tahrirniYop() {
  holat.tahrirId = null;
  render();
}

function filtrniTanla(filtr) {
  // Faqat boshMatnlar ning O'Z kalitlari — "constructor" filtr emas
  if (!Object.hasOwn(boshMatnlar, filtr)) {
    return;
  }
  holat.filtr = filtr;
  holat.tahrirId = null;
  filtrniYoz(filtr);
  render();
}

// Xotiraga va boshqa tablarga: ro'yxat xabarning o'zida ketadi
function saqlaVaTarqat() {
  saqla(holat.vazifalar);
  tarqat({ tur: "ozgardi", paket: eksportMatni(holat.vazifalar) });
}

// Ro'yxat o'zgarsa: xotiraga, boshqa tablarga, ekranga
function saqlaVaChiz() {
  saqlaVaTarqat();
  render();
}

// Yangi ro'yxat: obuna qayta ulanadi, filtr joyida qoladi
function royxatniUla(malumotlar) {
  holat.vazifalar = VazifalarRoyxati.dan(malumotlar);
  holat.vazifalar.obuna(saqlaVaChiz);
  const tahrirdagi = holat.tahrirId;
  if (tahrirdagi !== null && !holat.vazifalar.top(tahrirdagi)) {
    holat.tahrirId = null;
  }
}

// Import: ro'yxat almashadi, filtr "hammasi" ga qaytadi
function royxatniAlmashtir(malumotlar) {
  royxatniUla(malumotlar);
  holat.filtr = "hammasi";
  holat.tahrirId = null;
  filtrniYoz("hammasi");
  saqlaVaChiz();
}

// Server javobi yoki qaytarish: saqlab, fokusni joyida qoldiramiz
function royxatniYangila(malumotlar) {
  royxatniUla(malumotlar);
  saqlaVaTarqat();
  fondaChiz();
}

// Boshqa tab o'zgartirdi: ro'yxat xabarda keldi (saqlamaymiz!)
function boshqaTabdanYangila(xabar) {
  if (xabar?.tur !== "ozgardi") {
    return;
  }
  let malumotlar;
  try {
    malumotlar = paketniOqi(xabar.paket);
  } catch (xato) {
    console.warn(`Boshqa tab xabari o'qilmadi: ${xato.message}`);
    return;
  }
  royxatniUla(malumotlar);
  fondaChiz();
}

// Server bilan sinxron
let sinxronPulti = null;
let kutilmoqda = 0;

function tarmoqniBelgila(tarmoq) {
  holat.tarmoq = tarmoq;
  aloqaniYoz();
}

async function sinxronla() {
  if (kutilmoqda > 0) {
    return; // yozuvlar tugagach baribir chaqiriladi
  }
  sinxronPulti?.abort(); // eski so'rov endi kerak emas
  if (!navigator.onLine) {
    tarmoqniBelgila("oflayn");
    return;
  }
  const pult = new AbortController();
  sinxronPulti = pult;
  if (holat.tarmoq !== "tayyor") {
    tarmoqniBelgila("yuklanmoqda");
  }
  try {
    const sozlama = { signal: pult.signal };
    let { vazifalar, versiya } = await api.royxat(sozlama);
    if (versiya === 0) {
      // Server bu kalitni bilmaydi: ro'yxatni o'zimizdan yuboramiz
      const joriy = holat.vazifalar.toJSON();
      ({ vazifalar } = await api.almashtir(joriy, sozlama));
    }
    // Server javobi ham import kabi tekshiriladi
    const toza = paketniOqi(eksportMatni(vazifalar));
    if (eksportMatni(toza) !== eksportMatni(holat.vazifalar)) {
      royxatniYangila(toza);
    }
    tarmoqniBelgila("tayyor");
  } catch (xato) {
    if (xato.kod === "BEKOR") {
      return; // o'rniga yangi so'rov ketdi
    }
    console.warn(`Sinxron bo'lmadi: ${xato.message}`);
    tarmoqniBelgila(navigator.onLine ? "xato" : "oflayn");
  } finally {
    if (sinxronPulti === pult) {
      sinxronPulti = null;
    }
  }
}

// Optimistik yangilash: avval ekranda, keyin serverda;
// server rad etsa — oldingi holatga qaytaramiz
async function serverdaBajar(mahalliy, serverda) {
  if (holat.tarmoq === "oflayn") {
    fondaChiz(); // bosilgan katak ham eski holatiga qaytsin
    elonQil("Internet yo'q — o'zgarish saqlanmaydi.");
    return;
  }
  sinxronPulti?.abort(); // yo'ldagi ro'yxat endi eskirgan
  const oldingi = holat.vazifalar.toJSON();
  mahalliy();
  kutilmoqda++;
  try {
    await serverda();
  } catch (xato) {
    royxatniYangila(oldingi);
    elonQil(`Saqlanmadi: ${xato.message}`);
  } finally {
    kutilmoqda--;
    sinxronla(); // oxirgisi tugagach — server bilan solishtirish
  }
}

function tahrirniSaqla() {
  const kiritma = document.getElementById("tahrir-maydon");
  const id = holat.tahrirId;
  const matn = kiritma.value.trim();
  const xato = xatoniTop(matn, holat.vazifalar, id);
  if (xato !== null) {
    kiritma.setAttribute("aria-invalid", "true");
    kiritma.focus();
    elonQil(xato);
    return;
  }
  serverdaBajar(() => {
    matnniAlmashtir(id, matn);
    fokusla(id, "tahrirla", null);
    elonQil("O'zgarish saqlandi.");
  }, () => api.yangila(id, { matn }));
}

function tahrirniBekorQil() {
  const id = holat.tahrirId;
  tahrirniYop();
  fokusla(id, "tahrirla", null);
}

// Hodisalar: har joyda bitta tinglovchi
forma.addEventListener("submit", (event) => {
  event.preventDefault();
  elonQil("");
  const matn = maydon.value.trim();
  const xato = xatoniTop(matn, holat.vazifalar, null);
  if (xato !== null) {
    xatoniKorsat(xato);
    maydon.focus();
    return;
  }
  xatoniTozala();
  serverdaBajar(() => {
    vazifaQosh(matn);
    filtrniYoz(holat.filtr);
    forma.reset();
    elonQil(`«${matn}» qo'shildi.`);
  }, () => api.qosh(matn));
});

maydon.addEventListener("input", () => {
  if (maydon.getAttribute("aria-invalid") !== "true") {
    return;
  }
  const matn = maydon.value.trim();
  if (xatoniTop(matn, holat.vazifalar, null) === null) {
    xatoniTozala();
  }
});

royxat.addEventListener("click", (event) => {
  const nishon = event.target.closest("[data-amal]");
  if (nishon === null || !royxat.contains(nishon)) {
    return;
  }
  elonQil("");
  const amal = nishon.dataset.amal;
  const id = Number(nishon.closest("li").dataset.id);
  const zaxira = qoshniId(id);
  if (amal !== "saqla" && amal !== "bekor") {
    holat.tahrirId = null;
  }
  switch (amal) {
    case "almashtir": {
      const bajarildi = !vazifaniTop(id).bajarildi;
      serverdaBajar(() => {
        vazifaniAlmashtir(id);
        fokusla(id, "almashtir", zaxira);
      }, () => api.yangila(id, { bajarildi }));
      break;
    }
    case "ochir": {
      const { matn } = vazifaniTop(id);
      serverdaBajar(() => {
        elonQil(`«${matn}» o'chirildi.`);
        vazifaniOchir(id);
        fokusla(zaxira, "ochir", null);
      }, () => api.ochir(id));
      break;
    }
    case "tahrirla":
      tahrirniBoshla(id);
      tahrirgaOt();
      break;
    case "saqla":
      tahrirniSaqla();
      break;
    case "bekor":
      tahrirniBekorQil();
      break;
  }
});

royxat.addEventListener("keydown", (event) => {
  if (event.target.id !== "tahrir-maydon" || event.isComposing) {
    return;
  }
  if (event.key === "Enter") {
    event.preventDefault();
    tahrirniSaqla();
  } else if (event.key === "Escape") {
    tahrirniBekorQil();
  }
});

filtrlar.addEventListener("click", (event) => {
  const tugma = event.target.closest("[data-filtr]");
  if (tugma === null) {
    return;
  }
  elonQil("");
  filtrniTanla(tugma.dataset.filtr);
});

eksportTugma.addEventListener("click", () => {
  eksportMatn.value = eksportMatni(holat.vazifalar);
  eksportMatn.focus();
  eksportMatn.select();
  elonQil("JSON tayyor. Nusxa olish uchun Ctrl+C bosing.");
});

importTugma.addEventListener("click", () => {
  importXatosiniTozala();
  elonQil("");
  let malumotlar;
  try {
    malumotlar = paketniOqi(importMatn.value);
  } catch (xato) {
    if (xato instanceof VazifaXatosi) {
      importXatosiniKorsat(xato.message);
      return;
    }
    importXatosiniKorsat("Kutilmagan xato: import bajarilmadi.");
    throw xato;
  }
  serverdaBajar(() => {
    royxatniAlmashtir(malumotlar);
    elonQil(`${malumotlar.length} ta vazifa import qilindi.`);
  }, () => api.almashtir(malumotlar));
});

qaytaTugma.addEventListener("click", () => sinxronla());

// Orqaga/Oldinga: filtr URL'dan qayta o'qiladi
addEventListener("popstate", () => {
  holat.filtr = filtrniOl();
  holat.tahrirId = null;
  render();
});

// Internet uzildi yoki qaytdi
addEventListener("offline", () => {
  sinxronPulti?.abort();
  tarmoqniBelgila("oflayn");
});
addEventListener("online", () => sinxronla());

// Boshqa tablardagi o'zgarishlar
tingla(boshqaTabdanYangila);

// Ishga tushirish: avval keshdan darhol, keyin server
holat.filtr = filtrniOl();
holat.vazifalar.obuna(saqlaVaChiz);
render();
sinxronla();

assets/js/render.js:

js
// render.js — holatdan ekranga: chizish, fokus, e'lon
import { Vazifa } from "./vazifa.js";
import { holat, korinadiganlar } from "./holat.js";

export const boshMatnlar = Object.freeze({
  hammasi: "Hozircha vazifa yo'q. Birinchisini qo'shing!",
  faol: "Faol vazifa yo'q.",
  bajarilgan: "Bajarilgan vazifa hali yo'q.",
});

// Sahifa elementlari
export const forma = document.getElementById("yangi-forma");
export const maydon = document.getElementById("yangi-matn");
const maydonXato = document.getElementById("yangi-matn-xato");
export const filtrlar = document.getElementById("filtrlar");
const sarlavha = document.getElementById("royxat-sarlavha");
export const royxat = document.getElementById("royxat");
const boshXabar = document.getElementById("bosh-xabar");
const hisob = document.getElementById("hisob");
const teglarQatori = document.getElementById("teglar");
const elon = document.getElementById("elon");
export const eksportTugma = document.getElementById("eksport-tugma");
export const eksportMatn = document.getElementById("eksport-matn");
export const importMatn = document.getElementById("import-matn");
export const importTugma = document.getElementById("import-tugma");
const importXato = document.getElementById("import-xato");
const aloqa = document.getElementById("aloqa");
export const qaytaTugma = document.getElementById("qayta-tugma");

const aloqaMatnlari = Object.freeze({
  yuklanmoqda: "Server bilan solishtirilmoqda…",
  tayyor: "",
  xato: "Serverga ulanib bo'lmadi. Ro'yxat — shu brauzerdagi nusxa.",
  oflayn: "Internet yo'q. Ro'yxat — shu brauzerdagi nusxa.",
});

function tugmaYasa(amal, matn, nomi) {
  const tugma = document.createElement("button");
  tugma.type = "button";
  tugma.dataset.amal = amal;
  tugma.textContent = matn;
  tugma.setAttribute("aria-label", `${matn}: ${nomi}`);
  return tugma;
}

// Amal nomi va tugma matni — bir joyda, juftlik bilan
const tahrirTugmasi = tugmaYasa.bind(null, "tahrirla", "Tahrirlash");
const ochirTugmasi = tugmaYasa.bind(null, "ochir", "O'chirish");
const saqlaTugmasi = tugmaYasa.bind(null, "saqla", "Saqlash");
const bekorTugmasi = tugmaYasa.bind(null, "bekor", "Bekor qilish");

function oddiyQator(vazifa) {
  const li = document.createElement("li");
  li.dataset.id = vazifa.id;
  li.classList.toggle("bajarildi", vazifa.bajarildi);
  const katak = document.createElement("input");
  katak.type = "checkbox";
  katak.id = `vazifa-${vazifa.id}`;
  katak.dataset.amal = "almashtir";
  katak.checked = vazifa.bajarildi;
  const yorliq = document.createElement("label");
  yorliq.htmlFor = katak.id;
  yorliq.textContent = vazifa.matn;
  li.append(
    katak,
    yorliq,
    tahrirTugmasi(vazifa.matn),
    ochirTugmasi(vazifa.matn),
  );
  return li;
}

function tahrirQatori(vazifa) {
  const li = document.createElement("li");
  li.dataset.id = vazifa.id;
  const kiritma = document.createElement("input");
  kiritma.id = "tahrir-maydon";
  kiritma.value = vazifa.matn;
  kiritma.maxLength = Vazifa.MAKS_UZUNLIK;
  kiritma.setAttribute("aria-label", "Vazifa matni");
  li.append(
    kiritma,
    saqlaTugmasi(vazifa.matn),
    bekorTugmasi(vazifa.matn),
  );
  return li;
}

function qatorYasa(vazifa) {
  if (vazifa.id === holat.tahrirId) {
    return tahrirQatori(vazifa);
  }
  return oddiyQator(vazifa);
}

function matnniYoz(element, matn) {
  if (element.textContent !== matn) {
    element.textContent = matn;
  }
}

function teglarMatni(sanoq) {
  return [...sanoq]
    .map(([teg, soni]) => `${teg} — ${soni}`)
    .join(", ");
}

function teglarniYoz() {
  const sanoq = holat.vazifalar.tegHisobi();
  const yopilgan = [...holat.vazifalar.yopilganTeglar()];
  const qoshimcha =
    yopilgan.length > 0 ? `. Yopilgan: ${yopilgan.join(", ")}` : "";
  teglarQatori.hidden = sanoq.size === 0;
  matnniYoz(
    teglarQatori,
    `Teglar: ${teglarMatni(sanoq)}${qoshimcha}`,
  );
}

export function render() {
  royxat.replaceChildren(...korinadiganlar().map(qatorYasa));

  boshXabar.hidden = royxat.children.length > 0;
  matnniYoz(boshXabar, boshMatnlar[holat.filtr]);
  filtrlar.querySelectorAll("[data-filtr]").forEach((tugma) => {
    const tanlangan = tugma.dataset.filtr === holat.filtr;
    tugma.setAttribute("aria-pressed", String(tanlangan));
  });
  const { qoldi, bajarildi } = holat.vazifalar.statistika();
  matnniYoz(
    hisob,
    `${qoldi} ta vazifa qoldi, ${bajarildi} ta bajarildi`,
  );
  teglarniYoz();
  aloqaniYoz();
}

// Tarmoq holati: faqat yuqoridagi qator, ro'yxatga tegmaydi
export function aloqaniYoz() {
  matnniYoz(aloqa, aloqaMatnlari[holat.tarmoq]);
  qaytaTugma.hidden = holat.tarmoq !== "xato";
}

// Fon yangilanishi (server, boshqa tab, qaytarish): fokus va
// tahrirdagi matn joyida qoladi
export function fondaChiz() {
  const faol = document.activeElement;
  if (!royxat.contains(faol)) {
    render();
    return;
  }
  const id = Number(faol.closest("li").dataset.id);
  const amal = faol.dataset.amal;
  const qoralama = faol.id === "tahrir-maydon" ? faol.value : null;
  render();
  const kiritma = document.getElementById("tahrir-maydon");
  if (qoralama !== null && kiritma !== null) {
    kiritma.value = qoralama;
    kiritma.focus();
    return;
  }
  fokusla(id, amal, null);
}

// Fokus va e'lon: render'dan keyin
export function fokusla(id, amal, zaxiraId) {
  // querySelector — royxat'ning metodi: uni nuqta bilan chaqiramiz
  const top = (i) =>
    royxat.querySelector(`[data-id="${i}"] [data-amal="${amal}"]`);
  const nishon = top(id) ?? top(zaxiraId) ?? sarlavha;
  nishon.focus();
}

export function qoshniId(id) {
  const qator = royxat.querySelector(`[data-id="${id}"]`);
  const qoshni =
    qator.nextElementSibling ?? qator.previousElementSibling;
  return qoshni === null ? null : Number(qoshni.dataset.id);
}

const ELON_MUDDATI = 3000;
let elonTaymeri = null;

export function elonQil(matn) {
  clearTimeout(elonTaymeri);
  elon.textContent = matn;
  if (matn === "") {
    return;
  }
  elonTaymeri = setTimeout(() => {
    elon.textContent = "";
  }, ELON_MUDDATI);
}

export function xatoniKorsat(xabar) {
  maydonXato.textContent = xabar;
  maydon.setAttribute("aria-invalid", "true");
  maydon.setAttribute("aria-describedby", maydonXato.id);
}

export function xatoniTozala() {
  maydonXato.textContent = "";
  maydon.removeAttribute("aria-invalid");
  maydon.removeAttribute("aria-describedby");
}

export function tahrirgaOt() {
  const kiritma = document.getElementById("tahrir-maydon");
  kiritma.focus();
  kiritma.select();
}

export function importXatosiniKorsat(xabar) {
  importXato.textContent = xabar;
  importMatn.setAttribute("aria-invalid", "true");
  importMatn.focus();
}

export function importXatosiniTozala() {
  importXato.textContent = "";
  importMatn.removeAttribute("aria-invalid");
}

assets/js/holat.js:

js
// holat.js — ilova holati: yagona haqiqat manbai
import { VazifalarRoyxati } from "./royxat.js";
import { yukla } from "./saqlash.js";

function chuqurMuzlat(obyekt) {
  for (const qiymat of Object.values(obyekt)) {
    if (typeof qiymat === "object" && qiymat !== null) {
      chuqurMuzlat(qiymat);
    }
  }
  return Object.freeze(obyekt);
}

export const boshlangichVazifalar = chuqurMuzlat([
  { id: 1, matn: "Non olish", bajarildi: false },
  { id: 2, matn: "DOM darsini takrorlash", bajarildi: true },
  { id: 3, matn: "Onamga qo'ng'iroq qilish", bajarildi: false },
]);

// Ro'yxat keshdan darhol olinadi; server bilan keyin solishtiriladi
export const holat = {
  vazifalar: VazifalarRoyxati.dan(yukla() ?? boshlangichVazifalar),
  filtr: "hammasi",
  tahrirId: null,
  tarmoq: "yuklanmoqda", // "tayyor" | "xato" | "oflayn"
};

// So'rovlar: faqat qiymat qaytaradi
export function xatoniTop(matn, vazifalar, ozId) {
  if (matn === "") {
    return "Vazifa matnini yozing.";
  }
  if (vazifalar.matnBormi(matn, ozId)) {
    return "Bu vazifa ro'yxatda allaqachon bor.";
  }
  return null;
}

export function korinadiganlar() {
  return holat.vazifalar.korinadiganlar(holat.filtr);
}

export function vazifaniTop(id) {
  return holat.vazifalar.top(id);
}

// O'zgartiruvchilar: avval holat, oxirida royxat (u render'ni
// kuzatuvchi orqali chaqiradi)
export function vazifaQosh(matn) {
  if (holat.filtr === "bajarilgan") {
    holat.filtr = "hammasi";
  }
  holat.vazifalar.qosh(matn);
}

export function vazifaniAlmashtir(id) {
  holat.vazifalar.almashtir(id);
}

export function vazifaniOchir(id) {
  holat.vazifalar.ochir(id);
}

export function matnniAlmashtir(id, matn) {
  holat.tahrirId = null;
  holat.vazifalar.matnniOzgartir(id, matn);
}

assets/js/saqlash.js:

js
// saqlash.js — brauzer xotirasi: vazifalar keshi va server kaliti
import { eksportMatni, paketniOqi } from "./paket.js";

export const SAQLASH_KALITI = "vazifalar:v3";
const KALIT_NOMI = "vazifalar:kalit";

export function saqla(royxat) {
  try {
    localStorage.setItem(SAQLASH_KALITI, eksportMatni(royxat));
    return true;
  } catch (xato) {
    console.warn(`Vazifalar saqlanmadi: ${xato.name}`);
    return false;
  }
}

export function yukla() {
  let matn;
  try {
    matn = localStorage.getItem(SAQLASH_KALITI);
  } catch (xato) {
    console.warn(`Xotirani o'qib bo'lmadi: ${xato.name}`);
    return null;
  }
  if (matn === null) {
    return null;
  }
  try {
    return paketniOqi(matn);
  } catch (xato) {
    console.warn(`Saqlangan vazifalar buzuq: ${xato.message}`);
    return null;
  }
}

// Shu brauzerning serverdagi ro'yxati: kalit bir marta yaratiladi
export function kalitniOl() {
  try {
    let kalit = localStorage.getItem(KALIT_NOMI);
    if (kalit === null) {
      kalit = crypto.randomUUID();
      localStorage.setItem(KALIT_NOMI, kalit);
    }
    return kalit;
  } catch (xato) {
    console.warn(`Kalit saqlanmadi: ${xato.name}`);
    return crypto.randomUUID();
  }
}

assets/js/api.js:

js
// api.js — server bilan gaplashadigan yagona joy
const KUTISH_MS = 8000;

export class ApiXatosi extends Error {
  constructor(xabar, sozlama) {
    super(xabar, sozlama);
    this.name = "ApiXatosi";
    this.holat = sozlama?.holat ?? 0;
    this.kod = sozlama?.kod ?? "NOMALUM";
  }
}

function holatKodi(holat) {
  if (holat === 404) return "TOPILMADI";
  if (holat === 400 || holat === 422) return "NOTOGRI";
  if (holat >= 500) return "SERVER";
  return "HTTP";
}

function tarmoqXatosi(xato) {
  if (xato.name === "TimeoutError") {
    return new ApiXatosi("Server javob bermadi.", {
      kod: "VAQT", cause: xato,
    });
  }
  if (xato.name === "AbortError") {
    return new ApiXatosi("So'rov bekor qilindi.", {
      kod: "BEKOR", cause: xato,
    });
  }
  return new ApiXatosi("Internet aloqasini tekshiring.", {
    kod: "TARMOQ", cause: xato,
  });
}

async function sorovYubor(url, sozlama = {}) {
  const { metod = "GET", tana, signal, kutish = KUTISH_MS } = sozlama;
  const vaqt = AbortSignal.timeout(kutish);
  const sorov = {
    method: metod,
    headers: { Accept: "application/json" },
    signal: signal ? AbortSignal.any([signal, vaqt]) : vaqt,
  };
  if (tana !== undefined) {
    sorov.headers["Content-Type"] = "application/json";
    sorov.body = JSON.stringify(tana);
  }

  let javob;
  let matn;
  try {
    javob = await fetch(url, sorov);
    matn = await javob.text();
  } catch (xato) {
    throw tarmoqXatosi(xato);
  }

  let malumot = null;
  if (matn !== "") {
    try {
      malumot = JSON.parse(matn);
    } catch (xato) {
      throw new ApiXatosi("Server JSON qaytarmadi.", {
        holat: javob.status, kod: "JAVOB", cause: xato,
      });
    }
  }
  if (!javob.ok) {
    const xabar = malumot?.xato ?? `Server xatosi: ${javob.status}`;
    throw new ApiXatosi(xabar, {
      holat: javob.status, kod: holatKodi(javob.status),
    });
  }
  return malumot;
}

export function apiKlient({
  asosiyUrl, kalit, muvaffaqiyatsiz = false,
}) {
  const manzil = `${asosiyUrl}/vazifalar/${kalit}`;
  // Sinov rejimi: server har yozuvga ataylab 500 qaytaradi
  const oxiri = muvaffaqiyatsiz ? "?muvaffaqiyatsiz=1" : "";
  return {
    royxat: (sozlama) => sorovYubor(manzil, sozlama),
    qosh: (matn, sozlama) => sorovYubor(manzil + oxiri, {
      ...sozlama, metod: "POST", tana: { matn },
    }),
    yangila: (id, ozgarish, sozlama) =>
      sorovYubor(`${manzil}/${id}${oxiri}`, {
        ...sozlama, metod: "PATCH", tana: ozgarish,
      }),
    ochir: (id, sozlama) => sorovYubor(`${manzil}/${id}${oxiri}`, {
      ...sozlama, metod: "DELETE",
    }),
    almashtir: (vazifalar, sozlama) => sorovYubor(manzil + oxiri, {
      ...sozlama, metod: "PUT", tana: { vazifalar },
    }),
  };
}
To'liq fayllar: index.html, README.md, CSS qo'shimchasi, tekshiruvlar

index.html:

html
<!DOCTYPE html>
<html lang="uz">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Vazifalar — kichik vazifalar ro'yxati</title>
  <meta name="description"
        content="Vanilla JavaScript'da yozilgan vazifalar ro'yxati.">
  <link rel="stylesheet" href="assets/css/vazifalar.css">
  <link rel="modulepreload" href="assets/js/holat.js">
  <link rel="modulepreload" href="assets/js/render.js">
  <link rel="modulepreload" href="assets/js/royxat.js">
  <link rel="modulepreload" href="assets/js/paket.js">
  <link rel="modulepreload" href="assets/js/vazifa.js">
  <link rel="modulepreload" href="assets/js/saqlash.js">
  <link rel="modulepreload" href="assets/js/marshrut.js">
  <link rel="modulepreload" href="assets/js/sinxron.js">
  <link rel="modulepreload" href="assets/js/api.js">
  <script type="module" src="assets/js/asosiy.js"></script>
</head>
<body>
  <main>
    <h1>Vazifalar</h1>
    <p id="aloqa" role="status"></p>
    <button type="button" id="qayta-tugma"
            hidden>Qayta urinish</button>
    <form id="yangi-forma" novalidate>
      <label for="yangi-matn">Yangi vazifa</label>
      <div class="qator">
        <input id="yangi-matn" name="matn" required
               maxlength="100" autocomplete="off">
        <button type="submit">Qo'shish</button>
      </div>
      <p id="yangi-matn-xato" class="xato" role="status"></p>
    </form>
    <div id="filtrlar" role="group" aria-label="Filtr">
      <button type="button" data-filtr="hammasi"
              aria-pressed="true">Hammasi</button>
      <button type="button" data-filtr="faol"
              aria-pressed="false">Faol</button>
      <button type="button" data-filtr="bajarilgan"
              aria-pressed="false">Bajarilgan</button>
    </div>
    <h2 id="royxat-sarlavha" tabindex="-1">Ro'yxat</h2>
    <ul id="royxat" aria-labelledby="royxat-sarlavha"></ul>
    <p id="bosh-xabar" hidden></p>
    <p id="hisob" aria-live="polite"></p>
    <p id="teglar" hidden></p>
    <p id="elon" role="status"></p>
    <h2>Eksport</h2>
    <button type="button"
            id="eksport-tugma">JSON qilib chiqarish</button>
    <label for="eksport-matn">Vazifalar JSON ko'rinishida</label>
    <textarea id="eksport-matn" rows="6" readonly></textarea>
    <h2>Import</h2>
    <label for="import-matn">Eksport qilingan JSON matni</label>
    <textarea id="import-matn" rows="6"></textarea>
    <button type="button" id="import-tugma">Import qilish</button>
    <p id="import-xato" class="xato" aria-live="polite"></p>
    <p><small>v3: ro'yxat shu brauzerda va «Bahor» mashq
      serverida saqlanadi. Server — o'rganish uchun: u qayta
      ishga tushsa, ro'yxat shu brauzerdagi nusxadan
      tiklanadi.</small></p>
  </main>
</body>
</html>

README.md:

markdown
# Vazifalar

Vanilla JavaScript'da yozilgan vazifalar ro'yxati (v3).
Qo'shish, belgilash, tahrirlash, o'chirish, filtr va saralash;
statistika, teglar, JSON eksport va import. Ro'yxat brauzerda
saqlanadi va «Bahor» mashq API bilan sinxronlanadi. Klaviatura va
ekran o'quvchi bilan ishlaydi.

Sayt: https://login.github.io/vazifalar/

## Qanday ishlaydi

- Ochilishda ro'yxat darhol `localStorage` dan (kesh), keyin
  serverdan olinadi. Haqiqat manbai — server.
- O'zgarish avval ekranda ko'rinadi (optimistic update), keyin
  serverga ketadi. Server rad etsa — ro'yxat oldingi holatiga
  qaytadi va sababi e'lon qilinadi.
- Filtr URL'da: `?filtr=faol`. «Orqaga» tugmasi ishlaydi.
- Bir saytning tablari `BroadcastChannel` orqali sinxron.
- Internet yo'q bo'lsa — ro'yxat keshdan, o'zgartirish yopiq.

Server kaliti — `localStorage` dagi `vazifalar:kalit`
(`crypto.randomUUID()`). Kalitni bilgan odam ro'yxatni o'qiy va
o'zgartira oladi — uni ulashmang. Mashq API ma'lumotni xotirada
saqlaydi: server qayta ishga tushsa, ro'yxat keshdan tiklanadi.

## Modullar

Kirish nuqtasi — `assets/js/asosiy.js` (`<script type="module">`).
Import faqat pastga qaraydi, aylana yo'q.

- `vazifa.js` — `Vazifa`, `VazifaXatosi`: bitta vazifa.
- `royxat.js` — `VazifalarRoyxati`: to'plam, hisob, teglar, obuna.
- `paket.js` — eksport va import: `{ versiya: 1, vazifalar }`.
- `saqlash.js` — kesh (`vazifalar:v3`) va server kaliti.
- `holat.js` — `holat`, so'rovlar, o'zgartiruvchilar.
- `marshrut.js` — filtr URL'da.
- `sinxron.js` — tablar orasida xabar.
- `api.js` — server: `ApiXatosi`, `apiKlient`.
- `render.js` — DOM: chizish, fokus, e'lon, tarmoq holati.
- `asosiy.js` — tinglovchilar, sinxron va ishga tushirish.

Yangi modul qo'shilsa — `index.html` dagi `modulepreload`
ro'yxatini ham yangilang.

## Kod qoidalari

- Holat — yagona haqiqat manbai; ekranni faqat `render()` chizadi.
- Royxat faqat o'z metodlari bilan o'zgaradi va `obuna` orqali
  xabar beradi.
- `fetch` faqat `api.js` da. Server javobi ham `paketniOqi`
  tekshiruvidan o'tadi.
- Tinglovchi — arrow funksiya, parametri `event`; `this` yo'q.
- Foydalanuvchi va server matni — faqat `textContent`.

## Tekshirish

- Mantiq (Node 24): `node tekshiruv/royxat.js`,
  `node tekshiruv/paket.js`, `node tekshiruv/saqlash.js`,
  `node tekshiruv/marshrut.js` — hamma qator ✅.
- Server: `node tekshiruv/api.js` (internet kerak).
- Brauzer: `tekshiruv/ssenariy.md` dagi qadamlar.
- Xatoni sinash: manzilga `?muvaffaqiyatsiz=1` qo'shing — server
  har o'zgarishni rad etadi.

assets/css/vazifalar.css — 10-qismdagi fayl, oxiriga bitta qoida:

css
#aloqa {
  min-height: 1.5em;
  margin: 0 0 0.5rem;
  color: var(--rang-ikkinchi);
}

min-height — matn paydo bo'lganda sahifa pastga "sakramasin".

tekshiruv/saqlash.js:

js
// tekshiruv/saqlash.js — kesh va kalit: brauzersiz
// Ishga tushirish: node tekshiruv/saqlash.js
import "./xotira.js";
import {
  SAQLASH_KALITI, saqla, yukla, kalitniOl,
} from "../assets/js/saqlash.js";
import { VazifalarRoyxati } from "../assets/js/royxat.js";

function tekshir(nomi, haqiqiy, kutilgan) {
  const belgi = haqiqiy === kutilgan ? "✅" : "❌";
  console.log(`${belgi} ${nomi}: ${haqiqiy}`);
}

tekshir("bo'sh xotira", yukla(), null);

const royxat = VazifalarRoyxati.dan([
  { id: 4, matn: "Non olish", bajarildi: true },
]);
tekshir("saqlandi", saqla(royxat), true);
tekshir("o'qildi", yukla()[0].matn, "Non olish");

const bosh = VazifalarRoyxati.dan([]);
saqla(bosh);
tekshir("bo'sh ro'yxat — null emas", yukla().length, 0);

localStorage.setItem(SAQLASH_KALITI, "{buzuq");
tekshir("buzuq yozuv", yukla(), null);

const kalit = kalitniOl();
tekshir("kalit UUID", /^[0-9a-f-]{36}$/.test(kalit), true);
tekshir("kalit o'zgarmaydi", kalitniOl() === kalit, true);

tekshiruv/marshrut.js:

js
// tekshiruv/marshrut.js — filtr URL'dan: oq ro'yxat
// Ishga tushirish: node tekshiruv/marshrut.js
import { filtrniOl } from "../assets/js/marshrut.js";

const sayt = "https://login.github.io/vazifalar/";
const holatlar = [
  ["?filtr=faol", "faol"],
  ["?filtr=bajarilgan&muvaffaqiyatsiz=1", "bajarilgan"],
  ["?filtr=constructor", "hammasi"],
  ["?filtr=FAOL", "hammasi"],
  ["", "hammasi"],
];

for (const [qism, kutilgan] of holatlar) {
  const haqiqiy = filtrniOl(sayt + qism);
  const belgi = haqiqiy === kutilgan ? "✅" : "❌";
  console.log(`${belgi} «${qism}» → ${haqiqiy}`);
}

tekshiruv/api.js:

js
// tekshiruv/api.js — api.js haqiqiy mashq API bilan
// Ishga tushirish: node tekshiruv/api.js
import { apiKlient, ApiXatosi } from "../assets/js/api.js";

const asosiyUrl = "https://ilmhamroh.uz/api/mashq";
const api = apiKlient({ asosiyUrl, kalit: crypto.randomUUID() });

async function xatoniKor(nomi, ish) {
  try {
    await ish();
    console.log(`${nomi}: xato chiqmadi ❌`);
  } catch (xato) {
    const belgi = xato instanceof ApiXatosi ? "✅" : "❌";
    const { kod, holat, message } = xato;
    console.log(`${nomi}: ${belgi} ${kod} ${holat} ${message}`);
  }
}

const bosh = await api.royxat();
const { vazifalar, versiya } = bosh;
console.log("Boshida:", vazifalar.length, "ta, versiya", versiya);

const kochdi = await api.almashtir([
  { id: 1, matn: "Non olish", bajarildi: false },
  { id: 3, matn: "Choy damlash", bajarildi: true },
]);
const idlar = kochdi.vazifalar.map((v) => v.id);
console.log("PUT:", idlar, kochdi.versiya);

const yangi = await api.qosh("Sabzi olish");
console.log("Qo'shildi:", yangi.id, yangi.matn);

const belgi = await api.yangila(1, { bajarildi: true });
console.log("Belgilandi:", belgi.matn, belgi.bajarildi);
console.log("O'chirildi:", await api.ochir(3));

const oxiri = await api.royxat();
const matnlar = oxiri.vazifalar.map((v) => v.matn);
console.log("Oxirida:", matnlar, oxiri.versiya);

const sinov = apiKlient({
  asosiyUrl, kalit: crypto.randomUUID(), muvaffaqiyatsiz: true,
});
await xatoniKor("Ataylab", () => sinov.qosh("Non olish"));
await xatoniKor("Yo'q id", () => api.ochir(999));
await xatoniKor("Vaqt", () => api.royxat({ kutish: 1 }));
await xatoniKor("Bekor", () =>
  api.royxat({ signal: AbortSignal.abort() }));

13. Baholash ro'yxati

Majburiy shartlar. Bittasi bajarilmasa, loyiha qabul qilinmaydi:

  • Sayt https://login.github.io/vazifalar/ da ochiladi, Console'da ilova xatosi yo'q.
  • Oltin ssenariyning 26 qadami o'tadi.
  • tekshiruv/ dagi beshta fayl — yo'q.
  • ?muvaffaqiyatsiz=1 da hech bir o'zgarish ekranda "osilib" qolmaydi.

Ballar (jami 100, o'tish — 80):

Bo'lim Nima tekshiriladi Ball
Kesh Ochilishda darhol keshdan; buzuq kesh ilovani yiqitmaydi 10
Sinxron versiya: 0 → PUT; server javobi paketniOqi dan o'tadi 15
Optimistik yangilash To'rt amal va import; nusxa toJSON() bilan; qaytarish 20
Poyga holati Eski GET bekor; yozuv paytida GET yo'q; BEKOR jim 10
Holatlar Yuklanmoqda, xato + «Qayta urinish», oflayn; role="status" 10
URL va tablar ?filtr=, «Orqaga»; ro'yxat xabarda, aks-sado yo'q 10
Fokus Fon yangilanishida fokus va tahrir matni joyida 10
Tekshiruv Beshta fayl , ssenariy 5
Git Kichik commitlar, PR, README 10

Har bo'limda: hammasi bajarilgan — to'liq ball, bitta kamchilik — yarmi, ko'p — 0. Jami 90–100 bo'lsa, portfolio kartasidagi vazifalar ni "v3: kesh, server sinxroni, oflayn" deb yangilang. Jami 80 dan kam bo'lsa, ball yo'qolgan bo'limning tayanch darsiga qayting.

14. Ko'p uchraydigan xatolar

14.1 Telefonda ochganda — crypto.randomUUID is not a function

Kompyuterdagi Live Server'ni telefondan http://192.168.1.5:5500 kabi manzil bilan ochdingiz va ilova umuman ishlamadi:

text
Uncaught TypeError: crypto.randomUUID is not a function

Tarjimasi: "crypto.randomUUID — funksiya emas." Funksiya bor-ku? Ha, lekin faqat xavfsiz kontekstda (secure context): https:// sahifada yoki localhost/127.0.0.1 da. Oddiy http:// va tarmoq IP manzilida brauzer bu xususiyatni umuman bermaydi — u undefined. isSecureContext xususiyati buni aytadi: false. Biz buni Chrome 154 da tekshirdik. Tuzatish: kompyuterda 127.0.0.1 dan oching. Telefonda esa GitHub Pages'dagi https:// saytni sinang.

14.2 Qaytarish ishlamaydi — nusxa emas, havola

const oldingi = holat.vazifalar; — tekshiruv savolidagi xato. Server rad etadi, e'lon chiqadi, lekin ekran o'zgarmaydi. Tuzatish: holat.vazifalar.toJSON().

14.3 Ro'yxat "sakraydi" — eski javob yangisini bosib ketdi

Sekin tarmoqda vazifa qo'shasiz — u paydo bo'ladi, keyin yo'qoladi va bir ozdan so'ng yana qaytadi. Sabab: abort() qatori unutilgan va yo'ldagi GET bekor qilinmagan. U qo'shishdan oldingi ro'yxatni olib keladi va ekranni o'shanga almashtiradi; keyingi sinxron esa vazifani qaytaradi. Tuzatish: serverdaBajar boshida sinxronPulti?.abort(), sinxronla boshida if (kutilmoqda > 0) return;.

14.4 Tablar cheksiz bir-biriga xabar yuboradi

boshqaTabdanYangila ichida royxatniYangila (u saqlaydi va tarqatadi) ishlatilsa — har xabar yangi xabar tug'diradi. Tuzatish: boshqa tabdan kelgan o'zgarishda faqat royxatniUla + fondaChiz (Tablar orasida aloqa).

14.5 PUT 422 qaytaradi

Server javobi: {"xato":"Ma'lumot noto'g'ri","maydonlar":{"vazifalar":"massiv bo'lishi kerak"}}. Sabab: tanaga massivning o'zi yuborilgan: tana: vazifalar. Server esa obyekt kutadi. Tuzatish: tana: { vazifalar }.

15. Hujumchi nigohi

  • Kalit — parol kabi. Kalitni bilgan har kim ro'yxatingizni o'qiy, o'zgartira va o'chira oladi — mashq API boshqa hech narsa so'ramaydi. Kalit localStorage da: sahifadagi har qanday skript uni o'qiy oladi (localStorage). Mashq uchun bu yetarli; haqiqiy ilovada esa login va token kerak (Autentifikatsiyali so'rovlar). Kalitni skrinshotda yoki chatda ulashmang.
  • Kalit URL'da. GET /vazifalar/<kalit> manzili server jurnallariga va brauzer tarixiga tushadi. Shuning uchun haqiqiy maxfiy ma'lumot (token) URL'da emas, Authorization sarlavhasida yuboriladi.
  • Server ham ishonchsiz manba. Kimdir sizning kalitingiz bilan serverga <img src=x onerror=...> matnli vazifa yozdi. Ilova uni faqat textContent bilan chizadi — kod bo'lib ishlamaydi (Kontentni o'zgartirish va XSS). 150 belgili matn yoki takror id esa paketniOqi dan o'tmaydi va ro'yxatni buzmaydi.
  • URL — foydalanuvchi qo'lida. ?filtr=constructor — filtrniOl oq ro'yxati uni "hammasi" ga aylantiradi (Prototip zanjiri).

16. Mashqlar

1-mashq (oson): oflaynda xira ro'yxat

Internet yo'q paytda ro'yxat biroz xira ko'rinsin: <body> ga oflayn klassi qo'shilsin, CSS'da body.oflayn #royxat { opacity: 0.6; }. Internet qaytganda — klass olib tashlansin. Qaysi funksiyaga bitta qator qo'shiladi?

Ishora: tarmoq holati o'zgarganda faqat bitta funksiya chaqiriladi. classList.toggle(nom, shart) — shart true bo'lsa qo'shadi, false bo'lsa olib tashlaydi (Klasslar va uslublar).

Yechim

render.js dagi aloqaniYoz ga:

js
export function aloqaniYoz() {
  matnniYoz(aloqa, aloqaMatnlari[holat.tarmoq]);
  qaytaTugma.hidden = holat.tarmoq !== "xato";
  document.body.classList.toggle(
    "oflayn",
    holat.tarmoq === "oflayn",
  );
}

assets/css/vazifalar.css oxiriga:

css
body.oflayn #royxat { opacity: 0.6; }

Boshqa fayl o'zgarmaydi: tarmoq holatini faqat aloqaniYoz chizadi. Rang emas, faqat xiralik — matn "Internet yo'q" baribir yuqorida yozilgan, rangni ko'rmaydigan foydalanuvchi ham bilib oladi.

2-mashq (o'rta): rad etilgan vazifa matni yo'qolmasin

?muvaffaqiyatsiz=1 da "Choy damlash" ni qo'shing: vazifa yo'qoladi, maydon esa bo'sh — forma.reset() matnni o'chirib yuborgan. Qo'shish rad etilsa, matn maydonga qaytsin va fokus maydonda bo'lsin.

Ishora: serverdaBajar ga ixtiyoriy uchinchi parametr qo'shing — xatodan funksiyasi, xatoda qaytarishdan keyin chaqiriladi. ?.() — funksiya bo'lsa chaqiradi, undefined bo'lsa jim o'tadi (?? va ?.).

Yechim

serverdaBajar — imzo va catch:

js
async function serverdaBajar(mahalliy, serverda, xatodan) {
  // ... boshi o'zgarmadi
  } catch (xato) {
    royxatniYangila(oldingi);
    elonQil(`Saqlanmadi: ${xato.message}`);
    xatodan?.();
  } finally {

Qo'shish tinglovchisida — uchinchi argument:

js
  serverdaBajar(() => {
    vazifaQosh(matn);
    filtrniYoz(holat.filtr);
    forma.reset();
    elonQil(`«${matn}» qo'shildi.`);
  }, () => api.qosh(matn), () => {
    maydon.value = matn;
    maydon.focus();
  });

Qolgan chaqiruvlar o'zgarmaydi — ular uchinchi argument bermaydi, xatodan esa undefined. matn — closure: xato bir soniyadan keyin kelsa ham, funksiya qaysi matn yozilganini eslab turadi (Closure).

3-mashq (qiyin): boshqa qurilmani ulash

Kalit faqat shu brauzerda. Telefonda ham shu ro'yxatni ko'rish uchun kalitni u yerga "ko'chirish" kerak. Sahifa oxiriga yangi bo'lim qo'shing:

html
<h2>Boshqa qurilma</h2>
<label for="kalit-maydon">Shu ro'yxat kaliti</label>
<input id="kalit-maydon" autocomplete="off" spellcheck="false">
<button type="button" id="kalit-tugma">Kalitni almashtirish</button>
<p id="kalit-xato" class="xato" aria-live="polite"></p>

Talablar: maydonda joriy kalit ko'rinib tursin (nusxa olish uchun). Foydalanuvchi boshqa kalitni qo'yib tugmani bossa — kalit 36 belgili bo'lishi kerak, aks holda #kalit-xato da "Kalit 36 belgidan iborat bo'lishi kerak.". To'g'ri bo'lsa — kalit saqlansin, eski kesh o'chirilsin va sahifa qayta yuklansin (location.reload()). Kesh nega o'chiriladi?

Ishora: saqlash.js ga kalitniYoz(kalit) yozing (localStorage.setItem va removeItem, try ichida). Kalitning o'zini apiKlient ga berishdan oldin bir marta o'qing — const kalit = kalitniOl();.

Yechim

saqlash.js:

js
// Boshqa qurilmadagi ro'yxatga ulanish: eski kesh kerak emas
export function kalitniYoz(kalit) {
  try {
    localStorage.setItem(KALIT_NOMI, kalit);
    localStorage.removeItem(SAQLASH_KALITI);
    return true;
  } catch (xato) {
    console.warn(`Kalit saqlanmadi: ${xato.name}`);
    return false;
  }
}

asosiy.js — klient yasalishi va yangi tinglovchi (importlarga kalitniYoz qo'shiladi):

js
const kalit = kalitniOl();
const api = apiKlient({
  asosiyUrl: "https://ilmhamroh.uz/api/mashq",
  kalit,
  muvaffaqiyatsiz: sahifa.searchParams.get("muvaffaqiyatsiz") === "1",
});

const kalitMaydon = document.getElementById("kalit-maydon");
const kalitXato = document.getElementById("kalit-xato");
kalitMaydon.value = kalit;

const kalitTugma = document.getElementById("kalit-tugma");
kalitTugma.addEventListener("click", () => {
  const yangi = kalitMaydon.value.trim().toLowerCase();
  if (yangi.length !== 36) {
    kalitXato.textContent =
      "Kalit 36 belgidan iborat bo'lishi kerak.";
    kalitMaydon.focus();
    return;
  }
  if (yangi !== kalit && kalitniYoz(yangi)) {
    location.reload();
  }
});

Eski kesh nega o'chiriladi? Yangi kalit serverda bo'lsa, server javobi baribir keshni almashtirardi. Lekin kalit noto'g'ri yozilgan bo'lsa, server uni bilmaydi (versiya: 0), ilova esa eski qurilmaning keshini o'sha kalitga yuborib qo'yardi. Kesh o'chirilsa, bunday holatda boshlang'ich uchta vazifa bilan yangi ro'yxat boshlanadi.

Sinash: birinchi tabda kalitni nusxalang. Boshqa brauzerda (yoki Chrome'ning Incognito oynasida) ilovani oching, kalitni qo'yib bosing — ro'yxat birinchisidagidek bo'ladi. Endi ikkalasi bitta server ro'yxatini ko'radi. Biri o'zgartirsa, ikkinchisi uni keyingi sinxronda — sahifani yangilaganda yoki o'zi biror amal qilganda — oladi. BroadcastChannel bu yerda yordam bermaydi: u faqat bitta brauzerning tablari orasida ishlaydi. Darhol ko'rsatish uchun server xabar yuborishi kerak — WebSocket va SSE aynan shu uchun.

17. Real ishda

  • Kesh + server — hamma joyda. Telegram, Gmail, Trello ochilganda avval eski ro'yxatni ko'rsatadi, keyin yangilaydi — bugungi "stale-while-revalidate". Brauzer keshi va Service Worker'da ham shu nom bilan uchraydi (Kesh strategiyalari).
  • Tayyor kutubxonalar. React'da TanStack Query xuddi bugungi ishni qiladi: so'rovni bekor qiladi (cancelQueries), nusxa oladi, xatoda qaytaradi va oxirida ro'yxatni qayta so'raydi (invalidateQueries) (TanStack Query III). React 19 da esa useOptimistic hook'i bor (useOptimistic). Ichida — siz yozgan serverdaBajar g'oyasi. React 17-qismda; hozir bilish shart emas.
  • Oflayn birinchi. Google Docs, Notion, Figma o'zgarishlarni navbatga yig'adi va internet qaytganda yuboradi; ziddiyatlarni maxsus algoritmlar hal qiladi (Offline-first ilova). Serverda bunga o'xshash muammo — Optimistic concurrency.
  • Intervyu. "Optimistic update nima va xatoda nima qilasiz?", "Race condition'ni qanday oldini olasiz?", "navigator.onLine ga ishonsa bo'ladimi?" — bu loyiha uchalasiga tayyor javob.

11-qism yakuni

Tabriklaymiz — "Asinxron JavaScript va tarmoq" qismi tugadi! Qism boshida setTimeout(0) ham sirli edi. Endi ilovangiz server bilan gaplashadi, xatoga chidaydi va internetsiz ham ochiladi. Bu qismda:

  • Asinxronlik asoslari: taymerlar, callback'lar, event loop, task va microtask.
  • Promise'lar: zanjir, async/await, ketma-ket va parallel, kombinatorlar, AbortController, asinxron iteratsiya.
  • Tarmoq: fetch, Request/Response, CORS, oqimlar, API klient, Axios, token yangilash, WebSocket va SSE.
  • Brauzer xotirasi: localStorage, cookie'lar, IndexedDB va kvota.
  • URL va oynalar: URL, History API va router, tablar orasida aloqa.

Oldinda nima bor

v3 ishlaydi, lekin asosiy.js ga qarang: 360 qatordan oshdi. Kod uslubi fayldan faylga biroz farq qiladi, avtomatik test esa hali yo'q — 26 qadamni har safar qo'lda bosamiz.

Keyingi qism — "JavaScript ilg'or mavzular va kod sifati". Avval matn qoliplari (RegExp), sana va vaqt (Date, Intl), funksional uslub va JS dvigatelining ichi. Keyin kod sifati: toza kod, birinchi avtomatik test, ESLint va Prettier. vazifalar ham shu vositalar bilan tozalanadi.

Xulosa

  • Haqiqat manbai — server; localStorage — kesh: ochilishda darhol ko'rsatiladi, keyin server nusxasi bilan almashtiriladi.
  • Optimistik yangilash: nusxa (toJSON()), ekranda darhol, serverga so'rov; xatoda — qaytarish va sababni e'lon qilish; oxirida — qayta sinxron.
  • Poyga holatidan himoya: eski GET — abort(), yozuv yo'lda turganda GET yo'q, BEKOR — xato emas.
  • Server javobi ham tashqi ma'lumot: paketniOqi(eksportMatni(...)) tekshiruvidan o'tadi.
  • Tablar orasida signal emas, ro'yxatning o'zi yuboriladi: localStorage o'zgarishi xabardan kech qolishi mumkin.
  • Holatlar ko'rinadi: yuklanmoqda, xato va «Qayta urinish», oflayn (navigator.onLine, online/offline); fon yangilanishi fokusni o'g'irlamaydi.
  • Optimallashtirish o'lchov bilan qayta ko'rib chiqiladi: paketniYukla() foydasini yo'qotdi va olib tashlandi.

Keyingi dars: RegExp asoslari va metodlari — 12-qism boshlanadi: matndan telefon raqami yoki email'ni bitta qolip bilan qidirish va tekshirish.

Manbalar

  • MDN: "Crypto: randomUUID() method", "Secure contexts", "Navigator: onLine property", "Window: online event" — developer.mozilla.org
  • MDN: "AbortController", "Using the Fetch API", "Window: localStorage property", "Broadcast Channel API" — developer.mozilla.org
  • TanStack Query: "Optimistic Updates" — tanstack.com/query
  • web.dev: "Keeping things fresh with stale-while-revalidate" — web.dev
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
JavaScript yakuniy loyiha: saqlanadigan va sinxronlanadigan vazifalar ilovasi (v3) — IlmHamroh