Files
brightwheel-exporter/dashboard/dashboard.js
T

805 lines
26 KiB
JavaScript

/**
* Brightwheel Exporter - Dashboard Controller
*/
// Application State
const state = {
students: [],
selectedStudentId: 'edda433b-2a24-4da7-bbd5-9f131b4b2565',
selectedStudentData: null,
isExporting: false,
isPaused: false,
isCancelled: false,
activities: [],
mediaQueue: [],
stats: {
activitiesCount: 0,
mediaTotal: 0,
mediaDownloaded: 0,
mediaFailed: 0,
bytesDownloaded: 0,
retryCount: 0
}
};
// DOM Elements
let el = {};
document.addEventListener('DOMContentLoaded', async () => {
initDOMElements();
setupEventListeners();
initDatePresets();
await checkSessionAndDiscoverStudents();
});
function initDOMElements() {
el = {
sessionDot: document.getElementById('sessionDot'),
sessionStatusText: document.getElementById('sessionStatusText'),
refreshSessionBtn: document.getElementById('refreshSessionBtn'),
studentsList: document.getElementById('studentsList'),
manualDetails: document.getElementById('manualDetails'),
manualStudentId: document.getElementById('manualStudentId'),
setManualIdBtn: document.getElementById('setManualIdBtn'),
quickDatePills: document.getElementById('quickDatePills'),
customDateRow: document.getElementById('customDateRow'),
startDate: document.getElementById('startDate'),
endDate: document.getElementById('endDate'),
catPhotos: document.getElementById('catPhotos'),
catCare: document.getElementById('catCare'),
catMilestones: document.getElementById('catMilestones'),
catAttendance: document.getElementById('catAttendance'),
catMessages: document.getElementById('catMessages'),
fmtZip: document.getElementById('fmtZip'),
fmtViewer: document.getElementById('fmtViewer'),
fmtJson: document.getElementById('fmtJson'),
fmtCsv: document.getElementById('fmtCsv'),
concurrencySelect: document.getElementById('concurrencySelect'),
startExportBtn: document.getElementById('startExportBtn'),
inProgressControls: document.getElementById('inProgressControls'),
pauseExportBtn: document.getElementById('pauseExportBtn'),
cancelExportBtn: document.getElementById('cancelExportBtn'),
progressStatusText: document.getElementById('progressStatusText'),
progressPercent: document.getElementById('progressPercent'),
progressBarFill: document.getElementById('progressBarFill'),
metricActivitiesCount: document.getElementById('metricActivitiesCount'),
metricMediaCount: document.getElementById('metricMediaCount'),
metricSize: document.getElementById('metricSize'),
metricRateLimit: document.getElementById('metricRateLimit'),
terminalLog: document.getElementById('terminalLog'),
clearLogBtn: document.getElementById('clearLogBtn')
};
}
function log(message, type = 'info') {
const line = document.createElement('div');
line.className = `log-line ${type}`;
const timestamp = new Date().toLocaleTimeString();
line.textContent = `[${timestamp}] ${message}`;
el.terminalLog.appendChild(line);
el.terminalLog.scrollTop = el.terminalLog.scrollHeight;
}
// -------------------------------------------------------------
// Session & Student Discovery
// -------------------------------------------------------------
async function checkSessionAndDiscoverStudents() {
log('Checking active Brightwheel browser session...', 'info');
// Check stored active student ID first
const stored = await chrome.storage.local.get(['lastActiveStudentId']);
if (stored.lastActiveStudentId) {
state.selectedStudentId = stored.lastActiveStudentId;
el.manualStudentId.value = stored.lastActiveStudentId;
}
try {
// 1. Try to fetch user/guardian profile to list students
const userRes = await fetchBrightwheelAPI('https://schools.mybrightwheel.com/api/v1/users/me');
if (userRes && userRes.success && userRes.data) {
el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Brightwheel Session Active';
log('Successfully connected to Brightwheel user profile.', 'success');
const userData = userRes.data;
const studentsFound = userData.students || userData.children || (userData.guardians && userData.guardians[0]?.students) || [];
if (studentsFound.length > 0) {
state.students = studentsFound;
renderStudentsList();
return;
}
}
// 2. Try fetching specific student profile if selected
if (state.selectedStudentId) {
const studentRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${state.selectedStudentId}`);
if (studentRes && studentRes.success && studentRes.data) {
el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Brightwheel Session Active';
const s = studentRes.data.student || studentRes.data;
state.students = [s];
state.selectedStudentData = s;
renderStudentsList();
log(`Loaded student profile: ${s.first_name || 'Student'} (${state.selectedStudentId})`, 'success');
return;
}
}
// Fallback: Default student card with manual ID
el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Session Connected';
state.students = [{
id: state.selectedStudentId,
first_name: 'Student',
last_name: `(${state.selectedStudentId.substring(0, 8)}...)`,
school_name: 'Brightwheel School'
}];
renderStudentsList();
log(`Using student UUID: ${state.selectedStudentId}`, 'info');
} catch (err) {
log(`Session check note: ${err.message}. Ready with manual ID.`, 'warn');
renderStudentsList();
}
}
async function fetchBrightwheelAPI(url) {
try {
const res = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'include'
});
if (!res.ok) {
return { success: false, status: res.status, statusText: res.statusText };
}
const json = await res.json();
return { success: true, status: res.status, data: json };
} catch (err) {
return { success: false, error: err.message };
}
}
function renderStudentsList() {
el.studentsList.innerHTML = '';
if (state.students.length === 0) {
el.studentsList.innerHTML = '<div class="loading-placeholder">No students auto-detected. Use manual UUID below.</div>';
return;
}
state.students.forEach((student) => {
const sId = student.id || student.object_id || state.selectedStudentId;
const isSelected = sId === state.selectedStudentId;
const firstName = student.first_name || 'My';
const lastName = student.last_name || 'Child';
const initials = (firstName[0] || 'B') + (lastName[0] || 'W');
const school = student.school_name || student.room_name || 'Brightwheel Account';
const card = document.createElement('div');
card.className = `student-card ${isSelected ? 'selected' : ''}`;
card.dataset.id = sId;
let avatarContent = initials;
if (student.picture_url || student.avatar_url) {
avatarContent = `<img src="${student.picture_url || student.avatar_url}" alt="${firstName}">`;
}
card.innerHTML = `
<div class="student-avatar">${avatarContent}</div>
<div class="student-info">
<h4>${firstName} ${lastName}</h4>
<p>${school} • <code>${sId.substring(0, 8)}...</code></p>
</div>
`;
card.addEventListener('click', () => {
document.querySelectorAll('.student-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
state.selectedStudentId = sId;
state.selectedStudentData = student;
el.manualStudentId.value = sId;
log(`Selected child: ${firstName} ${lastName} (${sId})`, 'info');
});
el.studentsList.appendChild(card);
});
}
// -------------------------------------------------------------
// Date Filters & Presets
// -------------------------------------------------------------
function initDatePresets() {
const today = new Date();
const currentYear = today.getFullYear();
el.startDate.value = '';
el.endDate.value = today.toISOString().split('T')[0];
}
function setDateRange(rangeType) {
const today = new Date();
const todayStr = today.toISOString().split('T')[0];
if (rangeType === 'all') {
el.startDate.value = '';
el.endDate.value = todayStr;
} else if (rangeType === 'this-year') {
el.startDate.value = `${today.getFullYear()}-01-01`;
el.endDate.value = todayStr;
} else if (rangeType === 'last-year') {
el.startDate.value = `${today.getFullYear() - 1}-01-01`;
el.endDate.value = `${today.getFullYear() - 1}-12-31`;
} else if (rangeType === 'last-90') {
const d = new Date();
d.setDate(d.getDate() - 90);
el.startDate.value = d.toISOString().split('T')[0];
el.endDate.value = todayStr;
}
}
// -------------------------------------------------------------
// Event Listeners
// -------------------------------------------------------------
function setupEventListeners() {
el.refreshSessionBtn.addEventListener('click', () => {
checkSessionAndDiscoverStudents();
});
el.setManualIdBtn.addEventListener('click', () => {
const customId = el.manualStudentId.value.trim();
if (!customId) return;
state.selectedStudentId = customId;
state.students = [{
id: customId,
first_name: 'Custom',
last_name: `(${customId.substring(0, 8)}...)`,
school_name: 'Brightwheel Profile'
}];
renderStudentsList();
chrome.storage.local.set({ lastActiveStudentId: customId });
log(`Updated active student ID: ${customId}`, 'info');
});
el.quickDatePills.addEventListener('click', (e) => {
const btn = e.target.closest('.pill-btn');
if (!btn) return;
document.querySelectorAll('.pill-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
setDateRange(btn.dataset.range);
});
el.clearLogBtn.addEventListener('click', () => {
el.terminalLog.innerHTML = '<div class="log-line info">[System] Log cleared.</div>';
});
el.startExportBtn.addEventListener('click', () => {
if (!state.isExporting) {
startExportProcess();
}
});
el.pauseExportBtn.addEventListener('click', () => {
state.isPaused = !state.isPaused;
el.pauseExportBtn.textContent = state.isPaused ? 'Resume' : 'Pause';
log(state.isPaused ? 'Export process PAUSED.' : 'Export process RESUMED.', 'warn');
});
el.cancelExportBtn.addEventListener('click', () => {
state.isCancelled = true;
log('Cancelling export process...', 'error');
});
}
// -------------------------------------------------------------
// Main Export Process Engine
// -------------------------------------------------------------
async function startExportProcess() {
const studentId = state.selectedStudentId;
if (!studentId) {
alert('Please select or enter a Student UUID first.');
return;
}
// Reset State
state.isExporting = true;
state.isPaused = false;
state.isCancelled = false;
state.activities = [];
state.mediaQueue = [];
state.stats = {
activitiesCount: 0,
mediaTotal: 0,
mediaDownloaded: 0,
mediaFailed: 0,
bytesDownloaded: 0,
retryCount: 0
};
updateUIForExportStart();
log(`Starting export for Student ID: ${studentId}`, 'info');
try {
// 1. Fetch Student Profile details
let studentProfile = state.selectedStudentData;
if (!studentProfile) {
log('Fetching student profile details...', 'info');
const pRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${studentId}`);
if (pRes && pRes.success && pRes.data) {
studentProfile = pRes.data.student || pRes.data;
} else {
studentProfile = { id: studentId, first_name: 'Student', last_name: 'Brightwheel' };
}
}
// 2. Fetch Activities Feed with pagination
await fetchAllActivities(studentId);
if (state.isCancelled) {
log('Export cancelled by user.', 'error');
finalizeExportUI(false);
return;
}
log(`Total matching activities retrieved: ${state.activities.length}`, 'success');
// 3. Extract media downloads queue if selected
if (el.catPhotos.checked) {
extractMediaQueue();
log(`Found ${state.mediaQueue.length} media files to download.`, 'info');
}
// 4. Download media files with concurrency and pacing
if (state.mediaQueue.length > 0) {
await downloadAllMediaFiles();
}
if (state.isCancelled) {
log('Export cancelled during media download.', 'error');
finalizeExportUI(false);
return;
}
// 5. Build ZIP & Bundled Archives
await buildAndDownloadZip(studentProfile);
log('🎉 Export completed successfully! Enjoy your memories.', 'success');
updateProgress(100, 'Export Complete!');
finalizeExportUI(true);
} catch (err) {
log(`Fatal Export Error: ${err.message}`, 'error');
console.error(err);
finalizeExportUI(false);
}
}
function updateUIForExportStart() {
el.startExportBtn.disabled = true;
el.startExportBtn.style.display = 'none';
el.inProgressControls.style.display = 'flex';
el.pauseExportBtn.textContent = 'Pause';
updateProgress(0, 'Initializing export...');
updateMetrics();
}
function finalizeExportUI(success) {
state.isExporting = false;
el.startExportBtn.disabled = false;
el.startExportBtn.style.display = 'flex';
el.inProgressControls.style.display = 'none';
if (!success) {
el.progressStatusText.textContent = 'Export Stopped';
}
}
function updateProgress(percent, statusText) {
el.progressPercent.textContent = `${Math.round(percent)}%`;
el.progressBarFill.style.width = `${percent}%`;
if (statusText) el.progressStatusText.textContent = statusText;
}
function updateMetrics() {
el.metricActivitiesCount.textContent = state.stats.activitiesCount;
el.metricMediaCount.textContent = `${state.stats.mediaDownloaded} / ${state.stats.mediaTotal}`;
const mb = (state.stats.bytesDownloaded / (1024 * 1024)).toFixed(1);
el.metricSize.textContent = `${mb} MB`;
el.metricRateLimit.textContent = state.stats.retryCount;
}
// -------------------------------------------------------------
// Activity Feed Harvester with Pagination & Backoff
// -------------------------------------------------------------
async function fetchAllActivities(studentId) {
const pageSize = 50;
let page = 1;
let hasMore = true;
const startDate = el.startDate.value;
const endDate = el.endDate.value;
const pacingDelay = parseInt(el.concurrencySelect.value) || 200;
updateProgress(5, 'Harvesting activity logs...');
while (hasMore && !state.isCancelled) {
while (state.isPaused && !state.isCancelled) {
await sleep(500);
}
if (state.isCancelled) break;
// Build URL query
let url = `https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities?page_size=${pageSize}&page=${page}`;
if (startDate) url += `&start_date=${startDate}`;
if (endDate) url += `&end_date=${endDate}`;
log(`Fetching activities page ${page}...`, 'info');
let response = null;
let retries = 0;
const maxRetries = 5;
while (retries < maxRetries) {
response = await fetchBrightwheelAPI(url);
if (response && response.status === 429) {
state.stats.retryCount++;
updateMetrics();
const backoffTime = Math.pow(2, retries) * 1000;
log(`Rate limited (429). Backing off for ${backoffTime / 1000}s...`, 'warn');
await sleep(backoffTime);
retries++;
} else {
break;
}
}
if (!response || !response.success || !response.data) {
// If page 1 failed or reached end
if (page === 1) {
// Try fallback endpoint /api/v1/activities?student_ids[]
log('Trying alternate activity feed endpoint...', 'info');
const altUrl = `https://schools.mybrightwheel.com/api/v1/activities?student_ids[]=${studentId}&page_size=${pageSize}&page=${page}`;
const altRes = await fetchBrightwheelAPI(altUrl);
if (altRes && altRes.success && altRes.data) {
response = altRes;
}
}
}
const rawActivities = response?.data?.activities || response?.data?.items || response?.data || [];
if (!Array.isArray(rawActivities) || rawActivities.length === 0) {
log(`No more activities found after page ${page - 1}. Done harvesting.`, 'info');
hasMore = false;
break;
}
// Filter and normalize activities
for (const act of rawActivities) {
if (matchesSelectedCategories(act)) {
state.activities.push(normalizeActivity(act));
}
}
state.stats.activitiesCount = state.activities.length;
updateMetrics();
updateProgress(Math.min(30, 5 + page * 2), `Harvested ${state.activities.length} activities (Page ${page})`);
if (rawActivities.length < pageSize) {
hasMore = false;
break;
}
page++;
await sleep(pacingDelay);
}
}
function matchesSelectedCategories(act) {
const type = (act.activity_type || act.action_type || act.type || '').toLowerCase();
if (act.media || act.image_url || act.video_url || type.includes('photo') || type.includes('video')) {
if (el.catPhotos.checked) return true;
}
if (type.includes('food') || type.includes('meal') || type.includes('nap') || type.includes('potty') || type.includes('diaper') || type.includes('med')) {
if (el.catCare.checked) return true;
}
if (type.includes('milestone') || type.includes('learn') || type.includes('observation') || type.includes('kudos')) {
if (el.catMilestones.checked) return true;
}
if (type.includes('checkin') || type.includes('checkout') || type.includes('attendance') || type.includes('health') || type.includes('incident')) {
if (el.catAttendance.checked) return true;
}
if (type.includes('note') || type.includes('message') || type.includes('announcement')) {
if (el.catMessages.checked) return true;
}
return true;
}
function normalizeActivity(act) {
const mediaObj = act.media || {};
const highResImage = mediaObj.image_url || act.image_url || mediaObj.url || act.url;
const highResVideo = mediaObj.video_url || act.video_url;
return {
id: act.id || act.object_id || Math.random().toString(36).substring(2),
created_at: act.created_at || act.event_date || new Date().toISOString(),
activity_type: act.activity_type || act.action_type || act.type || 'activity',
title: act.title || '',
note: act.note || act.description || act.body || '',
teacher_name: act.actor_name || act.created_by?.name || act.teacher_name || '',
image_url: highResImage || null,
video_url: highResVideo || null,
thumb_url: mediaObj.thumb_url || act.thumb_url || null,
food_type: act.food_type || act.meal_type || null,
amount_eaten: act.amount_eaten || act.quantity || null,
nap_start: act.start_time || act.nap_start || null,
nap_end: act.end_time || act.nap_end || null,
duration_minutes: act.duration_minutes || null,
potty_type: act.potty_type || act.diaper_type || null,
milestone_name: act.milestone_name || act.tag_name || null,
media_local_path: null // Populated during ZIP bundling
};
}
// -------------------------------------------------------------
// Media Downloader with Concurrency & Progress
// -------------------------------------------------------------
function extractMediaQueue() {
state.mediaQueue = [];
state.activities.forEach((act) => {
const url = act.video_url || act.image_url;
if (url) {
const isVideo = !!act.video_url || url.includes('.mp4') || act.activity_type === 'video';
const ext = isVideo ? 'mp4' : 'jpg';
const datePrefix = (act.created_at || '').substring(0, 10) || 'unknown_date';
const filename = `${datePrefix}_${act.id.substring(0, 12)}.${ext}`;
const folder = isVideo ? 'videos' : 'photos';
const yearMonth = datePrefix.substring(0, 7) || 'all';
const localZipPath = `${folder}/${yearMonth}/${filename}`;
act.media_local_path = localZipPath;
state.mediaQueue.push({
activityId: act.id,
url: url,
filename: filename,
localZipPath: localZipPath,
blob: null,
status: 'pending'
});
}
});
state.stats.mediaTotal = state.mediaQueue.length;
updateMetrics();
}
async function downloadAllMediaFiles() {
const queue = state.mediaQueue;
const concurrency = 3;
let activeIndex = 0;
updateProgress(35, `Downloading 0 / ${queue.length} media files...`);
async function worker() {
while (activeIndex < queue.length && !state.isCancelled) {
while (state.isPaused && !state.isCancelled) {
await sleep(500);
}
if (state.isCancelled) break;
const itemIndex = activeIndex++;
const item = queue[itemIndex];
if (!item) break;
try {
const blob = await fetchMediaBlob(item.url);
item.blob = blob;
item.status = 'done';
state.stats.mediaDownloaded++;
state.stats.bytesDownloaded += blob.size;
} catch (err) {
item.status = 'failed';
state.stats.mediaFailed++;
log(`Failed to download media ${item.filename}: ${err.message}`, 'warn');
}
updateMetrics();
const progressPercent = 35 + (state.stats.mediaDownloaded / queue.length) * 50;
updateProgress(progressPercent, `Downloading media: ${state.stats.mediaDownloaded} / ${queue.length}`);
}
}
const workers = [];
for (let i = 0; i < Math.min(concurrency, queue.length); i++) {
workers.push(worker());
}
await Promise.all(workers);
}
async function fetchMediaBlob(url) {
let retries = 0;
while (retries < 3) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.blob();
} catch (err) {
retries++;
if (retries >= 3) throw err;
await sleep(1000 * retries);
}
}
}
// -------------------------------------------------------------
// ZIP Archiving & Packaging
// -------------------------------------------------------------
async function buildAndDownloadZip(studentProfile) {
if (typeof JSZip === 'undefined') {
throw new Error('JSZip library is not loaded.');
}
updateProgress(88, 'Generating ZIP package...');
log('Packaging media and manifests into ZIP archive...', 'info');
const zip = new JSZip();
const childName = sanitizeFilename(`${studentProfile.first_name || 'Child'}_${studentProfile.last_name || ''}`).trim() || 'Brightwheel_Archive';
const rootFolder = zip.folder(childName);
// 1. Add Media Files
for (const item of state.mediaQueue) {
if (item.blob && item.status === 'done') {
rootFolder.file(item.localZipPath, item.blob);
}
}
// 2. Add JSON Manifest
if (el.fmtJson.checked) {
const manifestData = {
export_date: new Date().toISOString(),
student: studentProfile,
total_activities: state.activities.length,
activities: state.activities
};
rootFolder.file('manifest.json', JSON.stringify(manifestData, null, 2));
}
// 3. Add CSV Spreadsheet
if (el.fmtCsv.checked) {
const csvContent = generateCSV(state.activities);
rootFolder.file('activities.csv', csvContent);
}
// 4. Add Standalone Interactive Offline Viewer
if (el.fmtViewer.checked) {
try {
const templateRes = await fetch(chrome.runtime.getURL('dashboard/viewer_template.html'));
if (templateRes.ok) {
let viewerHtml = await templateRes.text();
// Embed manifest data into index.html for instant zero-server loading
const embeddedManifest = JSON.stringify({
export_date: new Date().toISOString(),
student: studentProfile,
activities: state.activities
});
viewerHtml = viewerHtml.replace(
'let ARCHIVE_DATA = window.__BRIGHTWHEEL_ARCHIVE__ || null;',
`let ARCHIVE_DATA = ${embeddedManifest};`
);
rootFolder.file('index.html', viewerHtml);
}
} catch (e) {
log('Note: could not embed standalone index.html viewer: ' + e.message, 'warn');
}
}
// 5. Generate ZIP Blob
updateProgress(94, 'Compressing archive...');
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
}, (metadata) => {
updateProgress(88 + (metadata.percent * 0.08), `Compressing ZIP: ${Math.round(metadata.percent)}%`);
});
const zipFilename = `${childName}_Brightwheel_Archive_${new Date().toISOString().split('T')[0]}.zip`;
// 6. Trigger Download
const blobUrl = URL.createObjectURL(zipBlob);
if (chrome.downloads) {
chrome.runtime.sendMessage({
action: 'DOWNLOAD_BLOB_URL',
url: blobUrl,
filename: zipFilename
}, (res) => {
if (!res || !res.success) {
triggerBrowserDownload(blobUrl, zipFilename);
}
});
} else {
triggerBrowserDownload(blobUrl, zipFilename);
}
log(`Archive created: ${zipFilename} (${(zipBlob.size / (1024 * 1024)).toFixed(2)} MB)`, 'success');
}
function triggerBrowserDownload(url, filename) {
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function generateCSV(activities) {
const headers = [
'Activity ID',
'Date & Time',
'Type',
'Title',
'Note / Caption',
'Logged By',
'Media File Path',
'Food / Meal',
'Amount Eaten',
'Nap Start',
'Nap End',
'Nap Mins',
'Potty Type',
'Milestone'
];
const rows = activities.map(a => [
escapeCSV(a.id),
escapeCSV(a.created_at),
escapeCSV(a.activity_type),
escapeCSV(a.title),
escapeCSV(a.note),
escapeCSV(a.teacher_name),
escapeCSV(a.media_local_path || a.video_url || a.image_url || ''),
escapeCSV(a.food_type || ''),
escapeCSV(a.amount_eaten || ''),
escapeCSV(a.nap_start || ''),
escapeCSV(a.nap_end || ''),
escapeCSV(a.duration_minutes || ''),
escapeCSV(a.potty_type || ''),
escapeCSV(a.milestone_name || '')
]);
return [headers.join(','), ...rows.map(r => r.join(','))].join('\r\n');
}
function escapeCSV(val) {
if (val === null || val === undefined) return '""';
const str = String(val).replace(/"/g, '""');
return `"${str}"`;
}
function sanitizeFilename(str) {
return str.replace(/[^a-zA-Z0-9_-]/g, '_');
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}