Детали

[html]
<!doctype html>
<html lang="ru">
<head>
  <meta charset="windows-1251" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Доска полевых заданий</title>
  <link rel="stylesheet" href="https://forumstatic.ru/files/001c/82/f2/14304.css?v=3">
  <script src="https://feather-tail.github.io/MyBB-scripts-bundle/js/theme-switcher/theme-iframe.js"></script>
  <script src="https://feather-tail.github.io/MyBB-scripts-bundle/js/font-resizer/font-resizer-iframe.js"></script>
</head>

<body>
<section class="fo-board" aria-label="Доска полевых заданий">
  <header class="fo-header">
    <div>
      <h1 class="fo-title">Доска полевых заданий</h1>
    </div>

    <div class="fo-counter">
      <span>По текущим фильтрам показано заданий: <strong id="foCount">0</strong></span>
    </div>
  </header>

  <section class="fo-controls" aria-label="Поиск и фильтры">
    <div class="fo-controls-grid">
      <div class="fo-field">
        <div class="fo-label">Поиск</div>
        <div class="fo-search">
          <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
          <input class="fo-input" id="q" type="search" placeholder="Название, локация, риск, ключевое слово…" autocomplete="off" />
        </div>
      </div>

      <div class="fo-field">
        <label class="fo-label" for="difficulty">Сложность</label>
        <select class="fo-select" id="difficulty">
          <option value="">Любая</option>
          <option value="1">I</option>
          <option value="2">II</option>
          <option value="3">III</option>
          <option value="4">IV</option>
        </select>
      </div>

      <div class="fo-field">
        <label class="fo-label" for="type">Тип</label>
        <select class="fo-select" id="type">
          <option value="">Любой</option>
          <option value="recon">Разведка / наблюдение</option>
          <option value="escort">Сопровождение</option>
          <option value="investigation">Расследование</option>
          <option value="supply">Доставка / подготовка</option>
          <option value="maintenance">Техподдержка / периметр</option>
          <option value="collection">Сбор ингредиентов</option>
          <option value="training">Тренировка (полевая)</option>
        </select>
      </div>

      <div class="fo-field">
        <label class="fo-label" for="location">Локация</label>
        <select class="fo-select" id="location">
          <option value="">Любая</option>
          <option value="campus">Кампус</option>
          <option value="salem">Салем</option>
          <option value="state">Штат</option>
          <option value="zone">Зона ответственности</option>
          <option value="restricted">Запретная зона</option>
        </select>
      </div>

      <div class="fo-field">
        <label class="fo-label" for="group">Размер группы</label>
        <select class="fo-select" id="group">
          <option value="">Любой</option>
          <option value="solo">Соло</option>
          <option value="2-3">2–3</option>
          <option value="3-4">3–4</option>
          <option value="4-6">4–6</option>
        </select>
      </div>

      <div class="fo-field">
        <label class="fo-label" for="risk">Риски</label>
        <select class="fo-select" id="risk">
          <option value="">Любые</option>
          <option value="magic">Магия</option>
          <option value="blight">Скверна</option>
          <option value="people">Люди / социальное</option>
          <option value="medical">Медицинские</option>
          <option value="weather">Погодные</option>
        </select>
      </div>

      <div class="fo-field">
        <label class="fo-label" for="sort">Сортировка</label>
        <select class="fo-select" id="sort">
          <option value="urgency">По срочности</option>
          <option value="newest">Сначала новые</option>
          <option value="difficulty">По сложности</option>
          <option value="reward">По награде</option>
        </select>
      </div>
    </div>
    <span class="fo-sr-only" aria-live="polite" id="foLive"></span>
  </section>

  <main class="fo-grid" id="grid" aria-label="Список заданий"></main>
  <div class="fo-empty" id="empty" hidden></div>
</section>

<script>
(() => {
  'use strict';

  const root = document.querySelector('.fo-board');
  if (!root) return;

  const $ = (sel, r = root) => r.querySelector(sel);

  const grid = $('#grid');
  const empty = $('#empty');
  const countEl = $('#foCount');
  const live = $('#foLive');

  const q = $('#q');
  const difficulty = $('#difficulty');
  const type = $('#type');
  const locationSel = $('#location');
  const group = $('#group');
  const risk = $('#risk');
  const sort = $('#sort');

  const DATA_URL = 'https://feathertail.ru/ks/pages/fieldops-tasks.json';

  const LABELS = {
    difficulty: { 1: 'I', 2: 'II', 3: 'III', 4: 'IV' },
    type: {
      recon: 'Разведка / наблюдение',
      escort: 'Сопровождение',
      investigation: 'Расследование',
      supply: 'Доставка / подготовка',
      maintenance: 'Техподдержка / периметр',
      collection: 'Сбор ингредиентов',
      training: 'Тренировка (полевая)',
    },
    location: {
      campus: 'Кампус',
      salem: 'Салем',
      state: 'Штат',
      zone: 'Зона ответственности',
      restricted: 'Запретная зона',
    },
    group: {
      solo: 'Соло',
      '2-3': '2–3',
      '3-4': '3–4',
      '4-6': '4–6',
    },
    risk: {
      magic: 'Магия',
      blight: 'Скверна',
      people: 'Люди, социальное',
      medical: 'Медицинские',
      weather: 'Погодные',
    },
    status: { open: 'Открыто', work: 'В работе' },
  };

  const ICONS = {
    difficulty: 'fa-signal',
    location: 'fa-location-dot',
    group: 'fa-people-group',
    solo: 'fa-user',
    type: 'fa-tag',
    risks: 'fa-triangle-exclamation',
    reward: 'fa-award',
    date: 'fa-clock',
    urgency: 'fa-bolt',
  };

  const REWARD_META = {
    practice: { icon: 'fa-award', text: (r) => `+${Number(r.amount || 0)} $` },
    money: {
      icon: 'fa-dollar-sign',
      text: (r) => `${Number(r.amount || 0)}${r.currency || '$'}`,
    },
    perk: { icon: 'fa-id-card', text: (r) => r.label || 'Бонус' },
    item: { icon: 'fa-box', text: (r) => r.label || 'Предмет' },
  };

  const norm = (s) => (s || '').toString().toLowerCase().trim();

  const getTaskUrl = (t) => {
    const u = t?.url || t?.episodeUrl || t?.topicUrl || t?.link;
    return typeof u === 'string' && u.trim() ? u.trim() : '';
  };

  const normalizeParticipants = (p) => {
    if (!p) return [];
    const arr = Array.isArray(p) ? p : [p];

    return arr
      .map((x) => {
        if (typeof x === 'string') return { name: x.trim(), url: '' };

        if (x && typeof x === 'object') {
          const name = (x.name || x.title || x.nick || '').toString().trim();
          const url = (x.url || x.href || x.link || '').toString().trim();
          if (!name && url) return { name: url, url };
          return name ? { name, url } : null;
        }

        return null;
      })
      .filter(Boolean)
      .filter((x) => x.name);
  };

  const formatDateRu = (iso) => {
    const d = new Date(iso + 'T00:00:00');
    if (Number.isNaN(d.getTime())) return iso;
    return d
      .toLocaleDateString('ru-RU', {
        day: '2-digit',
        month: 'short',
        year: 'numeric',
      })
      .replace(/\./g, '');
  };

  const urgencyLabel = (n) => {
    const v = Number(n) || 1;
    if (v >= 3) return 'высокая';
    if (v === 2) return 'средняя';
    return 'низкая';
  };

  const normalizeRewards = (reward) => {
    if (reward == null) return [];
    if (typeof reward === 'number') return [{ type: 'practice', amount: reward }];
    if (Array.isArray(reward)) return reward;
    if (typeof reward === 'object') return [reward];
    return [{ type: 'perk', label: String(reward) }];
  };

  const rewardText = (reward) => {
    const rewards = normalizeRewards(reward);
    if (!rewards.length) return '—';
    return rewards
      .map((r) => {
        const meta = REWARD_META[r.type] || {
          icon: 'fa-award',
          text: (x) => x.label || String(x.amount || 'Награда'),
        };
        return meta.text(r);
      })
      .join(' • ');
  };

  const rewardSortValue = (reward) => {
    const rewards = normalizeRewards(reward);
    let v = 0;
    for (const r of rewards) {
      if (r.type === 'practice') v += Number(r.amount) || 0;
      if (r.type === 'money') v += (Number(r.amount) || 0) * 0.5;
    }
    return v;
  };

  const createEl = (tag, props = {}, children = []) => {
    const el = document.createElement(tag);
    for (const [k, v] of Object.entries(props)) {
      if (k === 'class') el.className = v;
      else if (k === 'text') el.textContent = v;
      else if (k === 'html') el.innerHTML = v;
      else if (k.startsWith('data-')) el.setAttribute(k, v);
      else el.setAttribute(k, v);
    }
    for (const ch of children) el.appendChild(ch);
    return el;
  };

  const getClampMetrics = (el) => {
    const cs = getComputedStyle(el);
    const lines = Math.max(1, parseInt(cs.getPropertyValue('--fo-desc-lines'), 10) || 3);
    let lh = parseFloat(cs.lineHeight);
    if (!Number.isFinite(lh)) {
      const fs = parseFloat(cs.fontSize) || 13;
      lh = fs * 1.45;
    }
    const maxH = lh * lines + 0.5;
    return { maxH };
  };

  const clampDesc = (el) => {
    const full = (el.dataset.full ?? el.textContent ?? '').toString();
    el.dataset.full = full;

    el.textContent = full;

    const { maxH } = getClampMetrics(el);
    if (el.scrollHeight <= maxH) {
      el.dataset.clamped = '0';
      return false;
    }

    let lo = 0;
    let hi = full.length;
    let best = 0;

    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      el.textContent = full.slice(0, mid) + '…';
      if (el.scrollHeight <= maxH) {
        best = mid;
        lo = mid + 1;
      } else {
        hi = mid - 1;
      }
    }

    let cut = full.slice(0, best);
    const cutByWord = cut.replace(/\s+\S*$/, '').trimEnd();
    cut = cutByWord.length >= 12 ? cutByWord : cut.trimEnd();

    el.textContent = cut + '…';
    el.dataset.clamped = '1';
    return true;
  };

  const modal = (() => {
    const wrap = createEl('div', {
      class: 'fo-modal',
      hidden: '',
      role: 'dialog',
      'aria-modal': 'true',
      'aria-labelledby': 'foModalTitle',
    });
    const panel = createEl('div', { class: 'fo-modal__panel' });
    const head = createEl('div', { class: 'fo-modal__head' });

    const title = createEl('h3', {
      class: 'fo-modal__title',
      id: 'foModalTitle',
      text: '',
    });
    const close = createEl('button', {
      class: 'fo-modal__close',
      type: 'button',
      'aria-label': 'Закрыть',
      text: 'x',
    });

    const content = createEl('div', { class: 'fo-modal__content' });

    const media = createEl('div', { class: 'fo-modal__media' }, [
      createEl('img', {
        id: 'foModalImg',
        alt: 'Карта задания',
        loading: 'lazy',
      }),
    ]);

    const meta = createEl('div', {
      class: 'fo-modal__meta',
      id: 'foModalMeta',
    });

    const peopleWrap = createEl(
      'div',
      { class: 'fo-modal__people', id: 'foModalPeopleWrap', hidden: '' },
      [
        createEl('div', { class: 'fo-modal__people-h', text: 'Участники:' }),
        createEl('ul', { class: 'fo-modal__people-list', id: 'foModalPeople' }),
      ],
    );

    const body = createEl('div', { class: 'fo-modal__body' }, [
      createEl('p', { class: 'fo-modal__desc', id: 'foModalDesc', text: '' }),
      peopleWrap,
    ]);

    head.append(title, close);
    content.append(media, meta);
    panel.append(head, content, body);
    wrap.append(panel);
    root.append(wrap);

    let opened = false;
    let prevOverflow = '';

    const closeModal = () => {
      if (!opened) return;
      opened = false;
      wrap.hidden = true;
      wrap.setAttribute('aria-hidden', 'true');
      document.documentElement.style.overflow = prevOverflow;
      close.blur();
    };

    const row = (label, icon, value, titleText = '') => {
      const v = (value ?? '—').toString();
      return createEl('div', { class: 'fo-modal__row' }, [
        createEl('div', { class: 'fo-modal__k' }, [
          createEl('i', { class: `fa-solid ${icon}`, 'aria-hidden': 'true' }),
          createEl('span', { text: label }),
        ]),
        createEl('div', {
          class: 'fo-modal__v',
          text: v,
          title: titleText || v,
        }),
      ]);
    };

    const openModal = (t) => {
      prevOverflow = document.documentElement.style.overflow || '';
      document.documentElement.style.overflow = 'hidden';

      const taskUrl = getTaskUrl(t);
      title.innerHTML = '';

      if (t.status === 'work' && taskUrl) {
        title.append(
          createEl('a', {
            class: 'fo-titlelink',
            href: taskUrl,
            target: '_blank',
            rel: 'noopener noreferrer',
            text: t.title || 'Задание',
          }),
        );
      } else {
        title.textContent = t.title || 'Задание';
      }

      $('#foModalDesc', wrap).textContent = (t.summary || '').toString();

      const imgEl = $('#foModalImg', wrap);
      imgEl.src = t.image || '';
      imgEl.alt = t.imageAlt || 'Карта задания';

      const metaEl = $('#foModalMeta', wrap);
      metaEl.innerHTML = '';

      const diffShort = LABELS.difficulty[t.difficulty] || t.difficulty;
      metaEl.append(
        row('Статус', 'fa-circle', LABELS.status[t.status] || t.status),
        row('Сложность', ICONS.difficulty, diffShort, `Сложность ${diffShort}`),
        row('Локация', ICONS.location, LABELS.location[t.location] || t.location),
        row(
          'Размер группы',
          t.group === 'solo' ? ICONS.solo : ICONS.group,
          LABELS.group[t.group] || t.group,
        ),
        row('Тип', ICONS.type, LABELS.type[t.type] || t.type),
        row(
          'Риски',
          ICONS.risks,
          Array.isArray(t.risks) ? t.risks.map((r) => LABELS.risk[r] || r).join(', ') : '—',
        ),
        row('Награда', ICONS.reward, rewardText(t.reward)),
      );

      metaEl.append(
        row('Дата', ICONS.date, formatDateRu(t.created || '')),
        row('Срочность', ICONS.urgency, urgencyLabel(t.urgency), `срочность: ${urgencyLabel(t.urgency)}`),
      );

      const people = normalizeParticipants(t.participants || t.people || t.members || t.players);
      const peopleWrapEl = $('#foModalPeopleWrap', wrap);
      const peopleListEl = $('#foModalPeople', wrap);

      if (peopleListEl) peopleListEl.innerHTML = '';

      if (people.length && peopleWrapEl && peopleListEl) {
        peopleWrapEl.hidden = false;

        for (const p of people) {
          const li = document.createElement('li');

          if (p.url) {
            li.append(
              createEl('a', {
                href: p.url,
                target: '_blank',
                rel: 'noopener noreferrer',
                text: p.name,
              }),
            );
          } else {
            li.textContent = p.name;
          }

          peopleListEl.append(li);
        }
      } else if (peopleWrapEl) {
        peopleWrapEl.hidden = true;
      }

      wrap.hidden = false;
      wrap.removeAttribute('aria-hidden');
      opened = true;
      close.focus();
    };

    close.addEventListener('click', closeModal);
    wrap.addEventListener('click', (e) => {
      if (e.target === wrap) closeModal();
    });
    document.addEventListener('keydown', (e) => {
      if (opened && e.key === 'Escape') closeModal();
    });

    return { openModal };
  })();

  const buildCard = (t) => {
    const statusText = LABELS.status[t.status] || t.status;
    const badgeClass =
      t.status === 'work' ? 'fo-badge fo-badge--work' : 'fo-badge fo-badge--open';

    const diffShort = LABELS.difficulty[t.difficulty] || String(t.difficulty || '');
    const diffFull = `Сложность ${diffShort}`;

    const locLabel = LABELS.location[t.location] || t.location;
    const groupLabel = LABELS.group[t.group] || t.group;

    const groupIcon = t.group === 'solo' ? 'fa-user' : 'fa-people-group';

    const risksText = (Array.isArray(t.risks) ? t.risks : [])
      .map((r) => LABELS.risk[r] || r)
      .join(', ');

    const article = createEl('article', {
      class: 'fo-card',
      'data-task-id': t.id || '',
    });

    const img = createEl('img', {
      src: t.image || '',
      alt: t.imageAlt || 'Карта локации',
      loading: 'lazy',
    });

    const badge = createEl('span', { class: badgeClass }, [
      createEl('i', { class: 'fa-solid fa-circle', 'aria-hidden': 'true' }),
      createEl('span', { text: statusText }),
    ]);

    const media = createEl('div', { class: 'fo-media' }, [img, badge]);

    const taskUrl = getTaskUrl(t);

    const title =
      t.status === 'work' && taskUrl
        ? createEl('h2', { class: 'fo-h' }, [
            createEl('a', {
              class: 'fo-hlink',
              href: taskUrl,
              target: '_blank',
              rel: 'noopener noreferrer',
              text: t.title || 'Без названия',
            }),
          ])
        : createEl('h2', {
            class: 'fo-h',
            text: t.title || 'Без названия',
          });

    const top = createEl('div', { class: 'fo-top' }, [title]);

    const meta = createEl('ul', { class: 'fo-meta' }, [
      createEl('li', {}, [
        createEl('i', { class: 'fa-solid fa-signal', 'aria-hidden': 'true' }),
        createEl('span', {
          class: 'fo-meta-text',
          text: diffShort,
          title: diffFull,
        }),
      ]),
      createEl('li', {}, [
        createEl('i', { class: 'fa-solid fa-location-dot', 'aria-hidden': 'true' }),
        createEl('span', {
          class: 'fo-meta-text',
          text: locLabel,
          title: locLabel,
        }),
      ]),
      createEl('li', {}, [
        createEl('i', { class: `fa-solid ${groupIcon}`, 'aria-hidden': 'true' }),
        createEl('span', { class: 'fo-meta-text', text: groupLabel }),
      ]),
    ]);

    const descText = (t.summary || '').toString();
    const desc = createEl('p', { class: 'fo-desc', text: descText });
    desc.dataset.full = descText;

    const moreBtn = createEl('button', {
      type: 'button',
      class: 'fo-more fo-more--ghost',
      text: 'Читать полностью',
    });

    moreBtn.addEventListener('click', () => modal.openModal(t));

    const actions = createEl('div', { class: 'fo-actions' }, [moreBtn]);

    const kv = createEl('div', { class: 'fo-kv' }, [
      createEl('div', { class: 'fo-kvrow' }, [
        createEl('div', { class: 'fo-k' }, [
          createEl('i', { class: 'fa-solid fa-tag', 'aria-hidden': 'true' }),
          createEl('span', { text: 'Тип' }),
        ]),
        createEl('div', { class: 'fo-v', text: LABELS.type[t.type] || t.type }),
      ]),
      createEl('div', { class: 'fo-kvrow' }, [
        createEl('div', { class: 'fo-k' }, [
          createEl('i', { class: 'fa-solid fa-triangle-exclamation', 'aria-hidden': 'true' }),
          createEl('span', { text: 'Риски' }),
        ]),
        createEl('div', { class: 'fo-v', text: risksText || '—' }),
      ]),
      createEl('div', { class: 'fo-kvrow' }, [
        createEl('div', { class: 'fo-k' }, [
          createEl('i', { class: 'fa-solid fa-award', 'aria-hidden': 'true' }),
          createEl('span', { text: 'Награда' }),
        ]),
        createEl('div', { class: 'fo-v', text: rewardText(t.reward) }),
      ]),
    ]);

    const foot = createEl('div', { class: 'fo-foot' }, [
      createEl('span', {}, [
        createEl('i', { class: 'fa-regular fa-clock', 'aria-hidden': 'true' }),
        document.createTextNode(formatDateRu(t.created || '')),
      ]),
      createEl('span', {}, [
        createEl('i', { class: 'fa-solid fa-bolt', 'aria-hidden': 'true' }),
        document.createTextNode(`срочность: ${urgencyLabel(t.urgency)}`),
      ]),
    ]);

    const body = createEl('div', { class: 'fo-body' }, [top, meta, desc, actions, kv, foot]);

    article.append(media, body);
    return article;
  };

  const buildSearchHaystack = (t) => {
    const rewardStr = rewardText(t.reward);
    const peopleStr = normalizeParticipants(t.participants || t.people || t.members || t.players)
      .map((p) => p.name)
      .join(' ');
    const parts = [
      t.title,
      t.summary,
      LABELS.type[t.type] || t.type,
      LABELS.location[t.location] || t.location,
      Array.isArray(t.risks) ? t.risks.map((r) => LABELS.risk[r] || r).join(' ') : '',
      LABELS.group[t.group] || t.group,
      LABELS.status[t.status] || t.status,
      String(t.difficulty || ''),
      peopleStr,
      rewardStr,
    ];
    return norm(parts.filter(Boolean).join(' '));
  };

  const sortKey = (t, mode) => {
    const urgency = Number(t.urgency) || 0;
    const created = new Date((t.created || '1970-01-01') + 'T00:00:00').getTime() || 0;
    const diff = Number(t.difficulty) || 0;
    const reward = rewardSortValue(t.reward);

    if (mode === 'newest') return created;
    if (mode === 'difficulty') return diff;
    if (mode === 'reward') return reward;
    return urgency;
  };

  let TASKS = [];
  let TASKS_BY_ID = new Map();

  const shouldEnableModalButton = (t, clamped) => {
    if (clamped) return true;
    if (!t) return false;
    if (t.status === 'work') return true;
    const people = normalizeParticipants(t.participants || t.people || t.members || t.players);
    return people.length > 0;
  };

  const applyDescClampAndButtons = () => {
    const cards = grid.querySelectorAll('.fo-card');
    cards.forEach((card) => {
      const desc = card.querySelector('.fo-desc');
      const btn = card.querySelector('.fo-more');
      if (!desc || !btn) return;

      const clamped = clampDesc(desc);

      const id = card.getAttribute('data-task-id') || '';
      const t = id ? TASKS_BY_ID.get(id) : null;

      const enable = shouldEnableModalButton(t, clamped);

      if (enable) {
        btn.classList.remove('fo-more--ghost');
        btn.disabled = false;
        btn.removeAttribute('aria-hidden');
        btn.tabIndex = 0;
      } else {
        btn.classList.add('fo-more--ghost');
        btn.disabled = true;
        btn.setAttribute('aria-hidden', 'true');
        btn.tabIndex = -1;
      }
    });
  };

  const render = () => {
    const needle = norm(q.value);
    const diffVal = difficulty.value;
    const typeVal = type.value;
    const locVal = locationSel.value;
    const groupVal = group.value;
    const riskVal = risk.value;
    const sortMode = sort.value;

    const filtered = TASKS.filter((t) => {
      if (diffVal && String(t.difficulty) !== diffVal) return false;
      if (typeVal && t.type !== typeVal) return false;
      if (locVal && t.location !== locVal) return false;
      if (groupVal && t.group !== groupVal) return false;

      const risks = Array.isArray(t.risks) ? t.risks : [];
      if (riskVal && !risks.includes(riskVal)) return false;

      if (needle) {
        const hay = t._hay || '';
        if (!hay.includes(needle)) return false;
      }
      return true;
    });

    filtered.sort((a, b) => sortKey(b, sortMode) - sortKey(a, sortMode));

    grid.innerHTML = '';
    const frag = document.createDocumentFragment();
    for (const t of filtered) frag.appendChild(buildCard(t));
    grid.appendChild(frag);

    applyDescClampAndButtons();

    countEl.textContent = String(filtered.length);
    live.textContent = `Показано заданий: ${filtered.length}`;

    if (filtered.length === 0) {
      empty.hidden = false;
      empty.textContent = 'Ничего не найдено. Попробуйте изменить фильтры или запрос.';
    } else {
      empty.hidden = true;
      empty.textContent = '';
    }
  };

  const bind = () => {
    [q, difficulty, type, locationSel, group, risk, sort].forEach((el) => {
      el.addEventListener('input', render);
      el.addEventListener('change', render);
    });

    let raf = 0;
    window.addEventListener(
      'resize',
      () => {
        if (raf) cancelAnimationFrame(raf);
        raf = requestAnimationFrame(() => {
          applyDescClampAndButtons();
        });
      },
      { passive: true },
    );
  };

  const load = async () => {
    empty.hidden = false;
    empty.textContent = 'Загрузка заданий…';

    try {
      const res = await fetch(DATA_URL, { cache: 'no-store' });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      TASKS = Array.isArray(data.tasks) ? data.tasks : [];

      TASKS = TASKS.map((t) => {
        const tt = { ...t };
        tt._hay = buildSearchHaystack(tt);
        return tt;
      });

      TASKS_BY_ID = new Map();
      for (const t of TASKS) {
        if (t && t.id) TASKS_BY_ID.set(String(t.id), t);
      }

      bind();
      render();
    } catch (e) {
      grid.innerHTML = '';
      countEl.textContent = '0';
      empty.hidden = false;
      empty.textContent = 'Не удалось загрузить JSON с заданиями. Проверьте URL или доступность файла.';
    }
  };

  load();
})();
</script>
</body>
</html>
[/html]
[hideprofile]