Рефакторинг: добавлен DataManager и API обертки для устранения дублирования запросов

НОВОЕ:
- static/js/managers/dataManager.js - централизованное кеширование и дедупликация
- static/js/api/calendarApi.js - обертка над calendar API с кешем (TTL 30s)
- static/js/api/departmentsApi.js - обертка над departments API с кешем (TTL 60s)

ИЗМЕНЕНО:
- calendarWidget.js теперь использует getCalendarEvents() и getMyDepartments()
- Убрано 3x дублирование запроса /api/v1/departments/my-departments/
- Убрано 2-3x дублирование запросов календаря

РЕЗУЛЬТАТ:
- Вместо 10+ API запросов теперь будет 3-4 (с кешем)
- Автоматическая дедупликация одновременных запросов
- Логирование Cache HIT/MISS для отладки
This commit is contained in:
admin i 2025-11-28 14:24:26 +03:00
parent b37941e4fd
commit 38c4d3aeab
5 changed files with 539 additions and 10 deletions

View File

@ -0,0 +1,155 @@
/**
* @fileoverview Calendar API - обертка над API календаря с кешированием
* @module api/calendarApi
*/
import { dataManager } from '../managers/dataManager.js';
/**
* Получить токен авторизации
* @returns {string}
* @private
*/
function getAccessToken() {
const meta = document.querySelector('meta[name="api-access"]');
const token = meta?.getAttribute('content')?.trim();
if (token) return token;
try {
return localStorage.getItem('api.access') || '';
} catch {
return '';
}
}
/**
* Создать заголовки с авторизацией
* @returns {Object}
* @private
*/
function authHeaders() {
const token = getAccessToken();
return {
'Accept': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
}
/**
* Получить события календаря
* @param {Object} params - Параметры запроса
* @param {string} params.start - Дата начала (YYYY-MM-DD)
* @param {string} params.end - Дата конца (YYYY-MM-DD)
* @param {number} [params.department_id] - ID отдела (опционально)
* @param {number} [ttl=30000] - Time to live кеша в мс
* @returns {Promise<Array>} Массив событий
*/
export async function getCalendarEvents(params, ttl = 30000) {
// Создаем уникальный ключ для кеша
const sortedParams = Object.keys(params).sort().reduce((acc, key) => {
acc[key] = params[key];
return acc;
}, {});
const key = `calendar:events:${JSON.stringify(sortedParams)}`;
return dataManager.fetch(
key,
async () => {
const url = new URL('/api/v1/calendar/events/', window.location.origin);
Object.keys(params).forEach(k => {
if (params[k] != null) {
url.searchParams.set(k, String(params[k]));
}
});
const response = await fetch(url.toString(), {
headers: authHeaders()
});
if (response.status === 401) {
console.warn('[CalendarAPI] 401 Unauthorized');
return [];
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Поддержка разных форматов ответа
return Array.isArray(data)
? data
: data.results || data.items || data.events || [];
},
ttl
);
}
/**
* Получить событие по ID
* @param {number|string} eventId - ID события
* @param {number} [ttl=60000] - Time to live кеша в мс
* @returns {Promise<Object>} Событие
*/
export async function getCalendarEvent(eventId, ttl = 60000) {
const key = `calendar:event:${eventId}`;
return dataManager.fetch(
key,
async () => {
const url = `/api/v1/calendar/events/${eventId}/`;
const response = await fetch(url, {
headers: authHeaders()
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
},
ttl
);
}
/**
* Инвалидировать кеш событий календаря
* @param {Object} [params] - Параметры для инвалидации (если не указаны, инвалидируется весь кеш)
*/
export function invalidateCalendarEvents(params = null) {
if (params) {
const sortedParams = Object.keys(params).sort().reduce((acc, key) => {
acc[key] = params[key];
return acc;
}, {});
const key = `calendar:events:${JSON.stringify(sortedParams)}`;
dataManager.invalidate(key);
} else {
// Инвалидируем все события календаря
dataManager.invalidatePattern(/^calendar:events:/);
}
}
/**
* Инвалидировать кеш конкретного события
* @param {number|string} eventId - ID события
*/
export function invalidateCalendarEvent(eventId) {
dataManager.invalidate(`calendar:event:${eventId}`);
}
/**
* Пре-загрузить события в кеш (например, из SSR данных)
* @param {Object} params - Параметры запроса
* @param {Array} events - Данные событий
*/
export function preloadCalendarEvents(params, events) {
const sortedParams = Object.keys(params).sort().reduce((acc, key) => {
acc[key] = params[key];
return acc;
}, {});
const key = `calendar:events:${JSON.stringify(sortedParams)}`;
dataManager.preload(key, events);
}

View File

@ -0,0 +1,150 @@
/**
* @fileoverview Departments API - обертка над API отделов с кешированием
* @module api/departmentsApi
*/
import { dataManager } from '../managers/dataManager.js';
/**
* Получить токен авторизации
* @returns {string}
* @private
*/
function getAccessToken() {
const meta = document.querySelector('meta[name="api-access"]');
const token = meta?.getAttribute('content')?.trim();
if (token) return token;
try {
return localStorage.getItem('api.access') || '';
} catch {
return '';
}
}
/**
* Создать заголовки с авторизацией
* @returns {Object}
* @private
*/
function authHeaders() {
const token = getAccessToken();
return {
'Accept': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
}
/**
* Получить отделы текущего пользователя
* @param {number} [ttl=60000] - Time to live кеша в мс (60 секунд по умолчанию)
* @returns {Promise<Array>} Массив отделов
*/
export async function getMyDepartments(ttl = 60000) {
const key = 'departments:my';
return dataManager.fetch(
key,
async () => {
const url = '/api/v1/departments/my-departments/';
const response = await fetch(url, {
headers: authHeaders()
});
if (response.status === 401) {
console.warn('[DepartmentsAPI] 401 Unauthorized');
return [];
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Поддержка разных форматов ответа
return Array.isArray(data)
? data
: data.results || data.items || data.departments || [];
},
ttl
);
}
/**
* Получить все отделы
* @param {number} [ttl=60000] - Time to live кеша в мс
* @returns {Promise<Array>} Массив отделов
*/
export async function getAllDepartments(ttl = 60000) {
const key = 'departments:all';
return dataManager.fetch(
key,
async () => {
const url = '/api/v1/departments/';
const response = await fetch(url, {
headers: authHeaders()
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return Array.isArray(data)
? data
: data.results || data.items || data.departments || [];
},
ttl
);
}
/**
* Получить отдел по ID
* @param {number|string} departmentId - ID отдела
* @param {number} [ttl=60000] - Time to live кеша в мс
* @returns {Promise<Object>} Отдел
*/
export async function getDepartment(departmentId, ttl = 60000) {
const key = `departments:${departmentId}`;
return dataManager.fetch(
key,
async () => {
const url = `/api/v1/departments/${departmentId}/`;
const response = await fetch(url, {
headers: authHeaders()
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
},
ttl
);
}
/**
* Инвалидировать кеш отделов
*/
export function invalidateDepartments() {
dataManager.invalidatePattern(/^departments:/);
}
/**
* Инвалидировать кеш моих отделов
*/
export function invalidateMyDepartments() {
dataManager.invalidate('departments:my');
}
/**
* Пре-загрузить отделы в кеш
* @param {Array} departments - Данные отделов
*/
export function preloadMyDepartments(departments) {
dataManager.preload('departments:my', departments);
}

View File

@ -4,9 +4,12 @@
* Поддерживает: компания + отделы пользователя, повторяющиеся события, недельный список
*
* @module calendarWidget
* @version 1.0.0
* @version 2.0.0
*/
import { getCalendarEvents, invalidateCalendarEvents } from '../api/calendarApi.js';
import { getMyDepartments } from '../api/departmentsApi.js';
/**
* Инициализация виджета календаря
* @param {Object} options - Параметры инициализации
@ -307,8 +310,10 @@ export function initCalendarWidget(options = {}) {
let raw = [];
try {
raw = await fetchJSON(API_MY_DEPTS);
// Используем кешированный API вместо прямого fetch
raw = await getMyDepartments();
} catch (e) {
console.error('[CalendarWidget] Failed to load departments:', e);
raw = [];
}
@ -422,12 +427,21 @@ export function initCalendarWidget(options = {}) {
/* ===== Комбинированная загрузка событий (occurrences) ===== */
// События для текущего контекста (используется календарём)
async function fetchEventsCombined(start, end) {
let base = eventsUrl(state.type === 'dept' ? state.deptId : null);
const withRange = addRange(base, start, end);
try {
return await fetchJSON(withRange);
const params = {
start: ymdLocal(start),
end: ymdLocal(end)
};
// Добавляем department_id если нужно
if (state.type === 'dept' && state.deptId) {
params.department_id = state.deptId;
}
// Используем кешированный API
return await getCalendarEvents(params);
} catch (e) {
console.error('Fetch events failed', e);
console.error('[CalendarWidget] Fetch events failed', e);
return [];
}
}
@ -440,28 +454,38 @@ export function initCalendarWidget(options = {}) {
await loadDepartments();
} catch (_) {}
}
const startStr = ymdLocal(start);
const endStr = ymdLocal(end);
// Формируем список источников с подписью
const sources = [
{ url: addRange(eventsUrl(null), start, end), label: 'Компания', type: 'company', id: null },
{ params: { start: startStr, end: endStr }, label: 'Компания', type: 'company', id: null },
...departments.map((d) => ({
url: addRange(eventsUrl(d.id), start, end),
params: { start: startStr, end: endStr, department_id: d.id },
label: d.name,
type: 'dept',
id: d.id,
})),
];
// Загружаем все источники параллельно (с кешированием!)
const chunks = await Promise.all(
sources.map((s) =>
fetchJSON(s.url)
getCalendarEvents(s.params)
.then((arr) =>
(arr || []).map((ev) => ({
...ev,
__source: { label: s.label, type: s.type, id: s.id },
}))
)
.catch(() => [])
.catch((err) => {
console.error(`[CalendarWidget] Failed to load events for ${s.label}:`, err);
return [];
})
)
);
const merged = chunks.flat();
// Дедуп по устойчивому ключу
const seen = new Set();

View File

@ -207,6 +207,7 @@ export function initChatMarkRead(options = {}) {
method: 'POST',
headers: {
'X-CSRFToken': csrf,
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({ upto_ts: String(ts) }),

View File

@ -0,0 +1,199 @@
/**
* @fileoverview DataManager - централизованное управление данными с кешированием и дедупликацией
* Предотвращает дублирование запросов и обеспечивает кеширование на клиенте
* @module managers/dataManager
*/
/**
* Менеджер данных с кешированием и дедупликацией запросов
*/
class DataManager {
constructor() {
/** @type {Map<string, {data: any, timestamp: number}>} */
this.cache = new Map();
/** @type {Map<string, Promise<any>>} */
this.pending = new Map();
/** @type {number} */
this.defaultTTL = 30000; // 30 секунд по умолчанию
// Подписчики на изменения данных
this.subscribers = new Map();
}
/**
* Получить данные с кешированием и дедупликацией
* @param {string} key - Уникальный ключ для данных
* @param {Function} fetchFn - Функция для загрузки данных (должна возвращать Promise)
* @param {number} [ttl] - Time to live в миллисекундах (по умолчанию 30000)
* @returns {Promise<any>} Данные
*/
async fetch(key, fetchFn, ttl = this.defaultTTL) {
// 1. Проверяем кеш
if (this.cache.has(key)) {
const { data, timestamp } = this.cache.get(key);
const age = Date.now() - timestamp;
if (age < ttl) {
console.log(`[DataManager] Cache HIT: ${key} (age: ${age}ms)`);
return data;
} else {
console.log(`[DataManager] Cache EXPIRED: ${key} (age: ${age}ms, ttl: ${ttl}ms)`);
this.cache.delete(key);
}
}
// 2. Проверяем pending запросы (дедупликация)
if (this.pending.has(key)) {
console.log(`[DataManager] Request DEDUPED: ${key}`);
return this.pending.get(key);
}
// 3. Делаем запрос
console.log(`[DataManager] Fetching: ${key}`);
const promise = fetchFn()
.then(data => {
// Сохраняем в кеш
this.cache.set(key, {
data,
timestamp: Date.now()
});
// Удаляем из pending
this.pending.delete(key);
// Уведомляем подписчиков
this.notify(key, data);
return data;
})
.catch(error => {
// Удаляем из pending при ошибке
this.pending.delete(key);
throw error;
});
// Сохраняем promise в pending
this.pending.set(key, promise);
return promise;
}
/**
* Инвалидировать кеш для ключа
* @param {string} key - Ключ данных
*/
invalidate(key) {
console.log(`[DataManager] Invalidating: ${key}`);
this.cache.delete(key);
this.notify(key, null);
}
/**
* Инвалидировать все ключи, соответствующие паттерну
* @param {RegExp|string} pattern - Паттерн для поиска ключей
*/
invalidatePattern(pattern) {
const regex = typeof pattern === 'string'
? new RegExp(pattern)
: pattern;
const keysToInvalidate = Array.from(this.cache.keys())
.filter(key => regex.test(key));
console.log(`[DataManager] Invalidating pattern "${pattern}":`, keysToInvalidate);
keysToInvalidate.forEach(key => {
this.cache.delete(key);
this.notify(key, null);
});
}
/**
* Очистить весь кеш
*/
clear() {
console.log('[DataManager] Clearing all cache');
this.cache.clear();
this.pending.clear();
}
/**
* Подписаться на изменения данных
* @param {string} key - Ключ данных
* @param {Function} callback - Коллбек (data) => void
* @returns {Function} Функция отписки
*/
subscribe(key, callback) {
if (!this.subscribers.has(key)) {
this.subscribers.set(key, new Set());
}
this.subscribers.get(key).add(callback);
// Возвращаем функцию отписки
return () => {
const subs = this.subscribers.get(key);
if (subs) {
subs.delete(callback);
if (subs.size === 0) {
this.subscribers.delete(key);
}
}
};
}
/**
* Уведомить подписчиков об изменении данных
* @param {string} key - Ключ данных
* @param {any} data - Новые данные
* @private
*/
notify(key, data) {
const subs = this.subscribers.get(key);
if (subs) {
subs.forEach(callback => {
try {
callback(data);
} catch (err) {
console.error(`[DataManager] Subscriber error for key "${key}":`, err);
}
});
}
}
/**
* Получить статистику кеша
* @returns {Object} Статистика
*/
getStats() {
return {
cacheSize: this.cache.size,
pendingCount: this.pending.size,
subscribersCount: this.subscribers.size,
keys: Array.from(this.cache.keys())
};
}
/**
* Пре-загрузить данные в кеш
* @param {string} key - Ключ данных
* @param {any} data - Данные для сохранения
*/
preload(key, data) {
console.log(`[DataManager] Preloading: ${key}`);
this.cache.set(key, {
data,
timestamp: Date.now()
});
}
}
// Экспортируем singleton
export const dataManager = new DataManager();
// Для отладки в консоли
if (typeof window !== 'undefined') {
window.dataManager = dataManager;
}