diff --git a/backend/static/js/utils/index.js b/backend/static/js/utils/index.js
index 7e888336..9e74239d 100644
--- a/backend/static/js/utils/index.js
+++ b/backend/static/js/utils/index.js
@@ -13,14 +13,33 @@
* import { esc } from '{% static "js/utils/stringUtils.js" %}';
*/
-// Утилиты работы со строками (будут созданы в Фазе 2)
-// export { esc, norm } from './stringUtils.js';
+// Утилиты работы со строками
+export {
+ esc,
+ norm,
+ escAttr,
+ truncate,
+ getInitials
+} from './stringUtils.js';
-// Утилиты для работы со временем (будут созданы в Фазе 2)
-// export { debounce, throttle } from './timing.js';
+// Утилиты для работы со временем
+export {
+ debounce,
+ throttle,
+ delay,
+ poll,
+ once
+} from './timing.js';
-// Утилиты прокрутки (будут созданы в Фазе 2)
-// export { smoothScrollTo } from './scroll.js';
+// Утилиты прокрутки
+export {
+ smoothScrollTo,
+ scrollToTop,
+ scrollToBottom,
+ isElementInViewport,
+ getScrollPosition,
+ onScrollThreshold
+} from './scroll.js';
-// Placeholder export для валидности модуля
+// Версия утилит
export const UTILS_VERSION = '1.0.0';
diff --git a/backend/static/js/utils/scroll.js b/backend/static/js/utils/scroll.js
new file mode 100644
index 00000000..59c50ce7
--- /dev/null
+++ b/backend/static/js/utils/scroll.js
@@ -0,0 +1,164 @@
+/**
+ * Scroll Utilities
+ *
+ * Утилиты для работы с прокруткой страницы
+ *
+ * Использование:
+ * import { smoothScrollTo, scrollToTop } from '{% static "js/utils/scroll.js" %}';
+ */
+
+/**
+ * Плавная прокрутка к элементу с учётом фиксированного navbar
+ *
+ * @param {HTMLElement|string} target - элемент или селектор для прокрутки
+ * @param {Object} options - опции прокрутки
+ * @param {number} options.offset - дополнительный отступ сверху (по умолчанию 20)
+ * @param {string} options.behavior - тип прокрутки ('smooth' или 'auto')
+ * @returns {void}
+ *
+ * @example
+ * // Прокрутка к элементу
+ * smoothScrollTo('#section2');
+ *
+ * // С дополнительным отступом
+ * smoothScrollTo(document.querySelector('.target'), { offset: 50 });
+ */
+export function smoothScrollTo(target, options = {}) {
+ const element = typeof target === 'string'
+ ? document.querySelector(target)
+ : target;
+
+ if (!element) {
+ console.warn('smoothScrollTo: element not found', target);
+ return;
+ }
+
+ const {
+ offset = 20,
+ behavior = 'smooth'
+ } = options;
+
+ // Высота фиксированного navbar (если есть)
+ const navbar = document.querySelector('.app-navbar, .navbar-fixed-top, .fixed-top');
+ const navbarHeight = navbar?.offsetHeight || 0;
+
+ // Позиция элемента относительно верха документа
+ const elementTop = element.getBoundingClientRect().top + window.pageYOffset;
+
+ // Финальная позиция с учётом navbar и offset
+ const scrollToPosition = elementTop - navbarHeight - offset;
+
+ window.scrollTo({
+ top: scrollToPosition,
+ behavior: behavior
+ });
+}
+
+/**
+ * Прокрутка в начало страницы
+ *
+ * @param {boolean} smooth - плавная прокрутка (по умолчанию true)
+ * @returns {void}
+ *
+ * @example
+ * scrollToTop(); // плавная прокрутка вверх
+ * scrollToTop(false); // мгновенная прокрутка
+ */
+export function scrollToTop(smooth = true) {
+ window.scrollTo({
+ top: 0,
+ behavior: smooth ? 'smooth' : 'auto'
+ });
+}
+
+/**
+ * Прокрутка в конец страницы
+ *
+ * @param {boolean} smooth - плавная прокрутка (по умолчанию true)
+ * @returns {void}
+ *
+ * @example
+ * scrollToBottom(); // плавная прокрутка вниз
+ */
+export function scrollToBottom(smooth = true) {
+ window.scrollTo({
+ top: document.documentElement.scrollHeight,
+ behavior: smooth ? 'smooth' : 'auto'
+ });
+}
+
+/**
+ * Проверка видимости элемента в viewport
+ *
+ * @param {HTMLElement} element - элемент для проверки
+ * @param {number} threshold - процент видимости (0-1)
+ * @returns {boolean} - виден ли элемент
+ *
+ * @example
+ * if (isElementInViewport(element, 0.5)) {
+ * console.log('Элемент виден более чем на 50%');
+ * }
+ */
+export function isElementInViewport(element, threshold = 0) {
+ const rect = element.getBoundingClientRect();
+ const windowHeight = window.innerHeight || document.documentElement.clientHeight;
+ const windowWidth = window.innerWidth || document.documentElement.clientWidth;
+
+ const vertInView = (rect.top <= windowHeight) && ((rect.top + rect.height) >= 0);
+ const horInView = (rect.left <= windowWidth) && ((rect.left + rect.width) >= 0);
+
+ if (threshold === 0) {
+ return vertInView && horInView;
+ }
+
+ // Процент видимости
+ const visibleHeight = Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0);
+ const visibleWidth = Math.min(rect.right, windowWidth) - Math.max(rect.left, 0);
+ const visibleArea = visibleHeight * visibleWidth;
+ const totalArea = rect.height * rect.width;
+
+ return (visibleArea / totalArea) >= threshold;
+}
+
+/**
+ * Получение текущей позиции скролла
+ *
+ * @returns {Object} - объект с координатами { x, y }
+ *
+ * @example
+ * const { x, y } = getScrollPosition();
+ * console.log('Scrolled:', x, y);
+ */
+export function getScrollPosition() {
+ return {
+ x: window.pageXOffset || document.documentElement.scrollLeft,
+ y: window.pageYOffset || document.documentElement.scrollTop
+ };
+}
+
+/**
+ * Скрытие/показ элемента при прокрутке (например, кнопки "наверх")
+ *
+ * @param {HTMLElement} element - элемент для показа/скрытия
+ * @param {number} threshold - порог прокрутки в пикселях
+ * @param {Function} callback - функция обратного вызова
+ * @returns {Function} - функция для отписки от события
+ *
+ * @example
+ * const btn = document.querySelector('#backToTop');
+ * const unsubscribe = onScrollThreshold(btn, 300, (visible) => {
+ * btn.style.display = visible ? 'block' : 'none';
+ * });
+ */
+export function onScrollThreshold(element, threshold, callback) {
+ const handleScroll = () => {
+ const scrollY = window.pageYOffset || document.documentElement.scrollTop;
+ callback(scrollY > threshold);
+ };
+
+ window.addEventListener('scroll', handleScroll);
+ handleScroll(); // вызвать сразу
+
+ // Возвращаем функцию для отписки
+ return () => window.removeEventListener('scroll', handleScroll);
+}
diff --git a/backend/static/js/utils/stringUtils.js b/backend/static/js/utils/stringUtils.js
new file mode 100644
index 00000000..718f2fd2
--- /dev/null
+++ b/backend/static/js/utils/stringUtils.js
@@ -0,0 +1,107 @@
+/**
+ * String Utilities
+ *
+ * Утилиты для работы со строками: экранирование, нормализация и т.д.
+ *
+ * Использование:
+ * import { esc, norm } from '{% static "js/utils/stringUtils.js" %}';
+ */
+
+/**
+ * Экранирование HTML символов для безопасного отображения
+ * Защищает от XSS атак при выводе пользовательского контента
+ *
+ * @param {string|number|null|undefined} s - строка для экранирования
+ * @returns {string} - экранированная строка
+ *
+ * @example
+ * esc('')
+ * // => '<script>alert("XSS")</script>'
+ *
+ * esc("O'Reilly & Sons")
+ * // => 'O'Reilly & Sons'
+ */
+export function esc(s) {
+ return String(s || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * Нормализация строки для поиска и сравнения
+ * Приводит к lowercase, заменяет множественные пробелы на один, убирает пробелы с краёв
+ *
+ * @param {string|null|undefined} s - строка для нормализации
+ * @returns {string} - нормализованная строка
+ *
+ * @example
+ * norm(' Иван Петров ')
+ * // => 'иван петров'
+ *
+ * norm('МОСКВА')
+ * // => 'москва'
+ */
+export function norm(s) {
+ return (s || '').toString().toLowerCase().replace(/\s+/g, ' ').trim();
+}
+
+/**
+ * Экранирование для использования в атрибутах HTML
+ * Более строгое, чем esc() - заменяет больше символов
+ *
+ * @param {string|null|undefined} s - строка для экранирования
+ * @returns {string} - экранированная строка для атрибута
+ *
+ * @example
+ * escAttr('value="test"')
+ * // => 'value="test"'
+ */
+export function escAttr(s) {
+ return String(s || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ .replace(/\//g, '/');
+}
+
+/**
+ * Усечение строки до указанной длины с добавлением многоточия
+ *
+ * @param {string} s - строка для усечения
+ * @param {number} maxLen - максимальная длина (включая многоточие)
+ * @returns {string} - усечённая строка
+ *
+ * @example
+ * truncate('Очень длинная строка текста', 15)
+ * // => 'Очень длинн...'
+ */
+export function truncate(s, maxLen) {
+ const str = String(s || '');
+ if (str.length <= maxLen) return str;
+ return str.slice(0, maxLen - 3) + '...';
+}
+
+/**
+ * Извлечение инициалов из ФИО
+ *
+ * @param {string} fullName - полное имя
+ * @returns {string} - инициалы
+ *
+ * @example
+ * getInitials('Иванов Иван Петрович')
+ * // => 'ИИ'
+ *
+ * getInitials('John Doe')
+ * // => 'JD'
+ */
+export function getInitials(fullName) {
+ const parts = String(fullName || '').trim().split(/\s+/);
+ if (parts.length === 0) return '';
+ if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
+ return (parts[0].charAt(0) + parts[1].charAt(0)).toUpperCase();
+}
diff --git a/backend/static/js/utils/timing.js b/backend/static/js/utils/timing.js
new file mode 100644
index 00000000..0fd629d8
--- /dev/null
+++ b/backend/static/js/utils/timing.js
@@ -0,0 +1,153 @@
+/**
+ * Timing Utilities
+ *
+ * Утилиты для управления временем выполнения функций: debounce, throttle и т.д.
+ *
+ * Использование:
+ * import { debounce, throttle } from '{% static "js/utils/timing.js" %}';
+ */
+
+/**
+ * Debounce - откладывает выполнение функции до окончания "тишины"
+ * Используется для оптимизации обработчиков событий (поиск, resize, scroll и т.д.)
+ *
+ * @param {Function} fn - функция для выполнения
+ * @param {number} ms - задержка в миллисекундах (по умолчанию 300)
+ * @returns {Function} - обёрнутая функция
+ *
+ * @example
+ * const handleSearch = debounce((query) => {
+ * console.log('Searching for:', query);
+ * }, 300);
+ *
+ * // При быстром наборе будет вызван только последний
+ * handleSearch('a'); // не выполнится
+ * handleSearch('ab'); // не выполнится
+ * handleSearch('abc'); // выполнится через 300мс после последнего вызова
+ */
+export function debounce(fn, ms = 300) {
+ let timeout;
+ return function(...args) {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => fn.apply(this, args), ms);
+ };
+}
+
+/**
+ * Throttle - ограничивает частоту выполнения функции
+ * Гарантирует выполнение не чаще чем раз в указанный интервал
+ *
+ * @param {Function} fn - функция для выполнения
+ * @param {number} ms - минимальный интервал между вызовами (по умолчанию 300)
+ * @returns {Function} - обёрнутая функция
+ *
+ * @example
+ * const handleScroll = throttle(() => {
+ * console.log('Scroll position:', window.scrollY);
+ * }, 100);
+ *
+ * // При скролле будет вызываться максимум раз в 100мс
+ * window.addEventListener('scroll', handleScroll);
+ */
+export function throttle(fn, ms = 300) {
+ let inThrottle;
+ let lastFn;
+ let lastTime;
+
+ return function(...args) {
+ const context = this;
+
+ if (!inThrottle) {
+ fn.apply(context, args);
+ lastTime = Date.now();
+ inThrottle = true;
+ } else {
+ clearTimeout(lastFn);
+ lastFn = setTimeout(() => {
+ if (Date.now() - lastTime >= ms) {
+ fn.apply(context, args);
+ lastTime = Date.now();
+ }
+ }, Math.max(ms - (Date.now() - lastTime), 0));
+ }
+ };
+}
+
+/**
+ * Delay - создаёт Promise, который разрешается через указанное время
+ * Полезно для async/await паттернов
+ *
+ * @param {number} ms - задержка в миллисекундах
+ * @returns {Promise} - Promise, который разрешится через указанное время
+ *
+ * @example
+ * async function loadData() {
+ * console.log('Loading...');
+ * await delay(1000);
+ * console.log('Loaded!');
+ * }
+ */
+export function delay(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+/**
+ * Polling - периодический вызов функции до выполнения условия
+ *
+ * @param {Function} fn - функция для выполнения (должна возвращать boolean)
+ * @param {number} interval - интервал между вызовами в мс
+ * @param {number} timeout - максимальное время ожидания в мс
+ * @returns {Promise} - Promise, который разрешится когда fn вернёт true
+ *
+ * @example
+ * // Ждём пока элемент появится в DOM
+ * await poll(
+ * () => document.querySelector('#myElement') !== null,
+ * 100, // проверяем каждые 100мс
+ * 5000 // максимум 5 секунд
+ * );
+ */
+export function poll(fn, interval = 100, timeout = 5000) {
+ const startTime = Date.now();
+
+ return new Promise((resolve, reject) => {
+ const checkCondition = () => {
+ if (fn()) {
+ resolve(true);
+ } else if (Date.now() - startTime >= timeout) {
+ reject(new Error('Polling timeout'));
+ } else {
+ setTimeout(checkCondition, interval);
+ }
+ };
+
+ checkCondition();
+ });
+}
+
+/**
+ * Once - гарантирует однократное выполнение функции
+ *
+ * @param {Function} fn - функция для выполнения
+ * @returns {Function} - обёрнутая функция
+ *
+ * @example
+ * const initialize = once(() => {
+ * console.log('Initialized!');
+ * });
+ *
+ * initialize(); // выведет "Initialized!"
+ * initialize(); // ничего не произойдёт
+ */
+export function once(fn) {
+ let called = false;
+ let result;
+
+ return function(...args) {
+ if (!called) {
+ called = true;
+ result = fn.apply(this, args);
+ }
+ return result;
+ };
+}
diff --git a/backend/templates/base.html b/backend/templates/base.html
index 8c98f5ad..9d8e619c 100644
--- a/backend/templates/base.html
+++ b/backend/templates/base.html
@@ -481,6 +481,26 @@
+ {# Импорт утилит из модулей #}
+
+