Jake Vanderwerf
2025-12-21 3aada9949d51024a92a8b5c6cb70d12f9c3cac16
assets/js/concise/UtilityFunctions.js
File was renamed from assets/js/dash/UtilityFunctions.js
@@ -19,7 +19,8 @@
   }
}
/**
 * Format a time value to "X time ago" format
 * Format a time value as relative time (past or future)
 * Handles both "X time ago" and "in X time" formats
 *
 * @param {string|Date} dateStr Date to format
 * @returns {string} Formatted time string
@@ -27,60 +28,47 @@
window.formatTimeAgo = function(dateStr) {
   const date = dateStr instanceof Date ? dateStr : new Date(dateStr);
   const now = new Date();
   const seconds = Math.floor((now - date) / 1000);
   const diffMs = date - now;
   const isPast = diffMs < 0;
   // Work with absolute values for calculations
   const seconds = Math.floor(Math.abs(diffMs) / 1000);
   const minutes = Math.floor(seconds / 60);
   const hours = Math.floor(minutes / 60);
   const days = Math.floor(hours / 24);
   if (hours < 24) {
   // Just now (within 1 minute either way)
   if (minutes === 0) {
      return 'Just now';
   }
   // Format the time components
   let timeStr = '';
   if (seconds < 10) {
      timeStr = 'a moment';
   } else if (seconds < 60) {
      timeStr = 'less than a minute'
   } else if (minutes < 5) {
      timeStr = 'a few minutes';
   } else if (hours < 24) {
      if (hours === 0) {
         return minutes === 0 ? 'Just now' : `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`;
         // Minutes only
         timeStr = `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
      } else {
         // Hours
         timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
      }
      return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
   } else if (days < 7) {
      // Days
      timeStr = `${days} ${days === 1 ? 'day' : 'days'}`;
   } else {
      // More than a week - just show the date
      return date.toLocaleDateString();
   }
   if (days < 7) {
      return `${days} ${days === 1 ? 'day' : 'days'} ago`;
   }
   return date.toLocaleDateString();
}
/**
 * Format a future date for display
 *
 * @param {string|Date} dateStr Future date
 * @returns {string} Formatted string
 */
window.formatTimeSoon = function(dateStr) {
   const date = dateStr instanceof Date ? dateStr : new Date(dateStr);
   const now = new Date();
   // Handle past dates
   if (date <= now) {
      return "Just now";
   }
   const seconds = Math.floor((date - now) / 1000);
   const minutes = Math.floor(seconds / 60);
   if (seconds < 60) {
      return "In a moment";
   }
   if (minutes < 5) {
      return "In a few minutes";
   }
   if (minutes < 20) {
      return "Coming up soon";
   }
   if (minutes < 60) {
      return "In about half an hour";
   }
   return "Later today";
   // Add appropriate prefix/suffix based on past or future
   return isPast ? `${timeStr} ago` : `in ${timeStr}`;
}
/**
@@ -136,55 +124,6 @@
}
/**
 * Formats vote from template
 * @param item
 * @param status
 * @returns {Node|ActiveX.IXMLDOMNode|boolean}
 */
window.formatVote = function(item, status) {
   let vote = window.getTemplate('voteButton');
   vote.dataset.itemId = item.id;
   vote.dataset.content = item.content;
   let up =vote.querySelector('button.up');
   let down =vote.querySelector('button.down');
   if(status === 'up'){
      up.classList.add('voted');
   }
   if(status === 'down'){
      down.classList.add('voted');
   }
   if(item.upvotes > 0){
      up.querySelector('.count').textContent = item.upvotes;
   }
   if(item.downvotes > 0){
      down.querySelector('.count').textContent = '-'+item.downvotes;
   }
   return vote;
}
/**
 * Tests if user has voted for this item
 * @param content
 * @param id
 * @returns {string}
 */
window.checkVoteStatus = function(content, id){
   if(!jvbSettings.currentUser){
      return '';
   }
   let status = '';
   if(window.userVotes && window.userVotes[content]?.has(id)){
      status = window.userVotes[content].get(id);
   }
   return status;
}
/**
 * Gets a clone of an icon element if it exists for efficient DOM manipulation
 * @param icon
 * @returns {Node | ActiveX.IXMLDOMNode}
@@ -211,15 +150,6 @@
}
/**
 * Tests for empty object
 * @param obj
 * @returns {boolean}
 */
window.isEmptyObject = function(obj) {
   return Object.keys(obj).length === 0;
}
/**
 * Format a number with comma separator (e.g., 1,234)
 * @param {number} num - Number to format
 * @returns {string} - Formatted number
@@ -261,17 +191,6 @@
}
/**
 * Truncate text to a specific length with ellipsis
 * @param {string} text - Text to truncate
 * @param {number} length - Maximum length
 * @returns {string} - Truncated text
 */
window.truncateText = function(text, length = 100) {
   if (!text || text.length <= length) return text;
   return text.substring(0, length) + '...';
}
/**
 * Should be faster than setting innerHTML = ''
 * @param node
 */
@@ -296,7 +215,7 @@
   // If same day, just show one date
   if (start.toDateString() === end.toDateString()) {
      return start.toLocaleDateString('en-US', {
      return start.toLocaleDateString('en-CA', {
         year: 'numeric',
         month: 'short',
         day: 'numeric'
@@ -305,43 +224,16 @@
   // If same month and year, show range with month once
   if (start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear()) {
      return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${end.getDate()}, ${end.getFullYear()}`;
      return `${start.toLocaleDateString('en-CA', { month: 'short', day: 'numeric' })} - ${end.getDate()}, ${end.getFullYear()}`;
   }
   // If same year, show full range with year once
   if (start.getFullYear() === end.getFullYear()) {
      return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${end.getFullYear()}`;
      return `${start.toLocaleDateString('en-CA', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-CA', { month: 'short', day: 'numeric' })}, ${end.getFullYear()}`;
   }
   // Different years, show full dates
   return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
}
/**
 * Debounce function to limit frequent calls
 * @param {Function} func - Function to debounce
 * @param {number} wait - Wait time in milliseconds
 * @returns {Function} - Debounced function
 */
window.debounce = function(func, wait = 300) {
   let timeout;
   return function(...args) {
      clearTimeout(timeout);
      timeout = setTimeout(() => func.apply(this, args), wait);
   };
}
window.throttle = function(func, limit) {
   let inThrottle;
   return function() {
      const args = arguments;
      const context = this;
      if (!inThrottle) {
         func.apply(context, args);
         inThrottle = true;
         setTimeout(() => inThrottle = false, limit);
      }
   }
   return `${start.toLocaleDateString('en-CA', { month: 'short', day: 'numeric', year: 'numeric' })} - ${end.toLocaleDateString('en-CA', { month: 'short', day: 'numeric', year: 'numeric' })}`;
}
@@ -737,60 +629,6 @@
   return !isNaN(parseFloat(n)) && isFinite(n);
};
window.handleListField = function (elem, value) {
   if (!Array.isArray(value)) {
      elem.remove();
      return;
   }
   let li = elem.querySelector('li');
   value.forEach((v) => {
      let l = li.cloneNode(true);
      l.textContent = v;
      elem.append(l);
   });
   li.remove();
};
window.handleTextField = function (elem, value) {
   if (typeof value !== "string") {
      elem.remove();
      return;
   }
   elem.textContent = value;
};
window.handleImageField = function (elem, value) {
   if (!Array.isArray(value) || value === 0) {
      elem.remove();
      return;
   }
   let img = (elem.tagName === 'IMG') ? elem : elem.querySelector('img');
   if (!img) {
      elem.remove();
      return;
   }
   img.alt = value.alt;
   img.src = value.thumbnail;
   img.dataset.small = value.small;
   img.dataset.medium = value.medium;
   img.dataset.large = value.full;
};
window.handleGalleryField = function (elem, value)
{
   if (!Array.isArray(value)) {
      elem.remove();
      return;
   }
   let img = elem.querySelector('img');
   value.forEach((v) => {
      let i = img.cloneNode(true);
      window.handleImageField(i, v);
      elem.append(i);
   });
   img.remove();
};
/**
 *
 * @param {object} selectors
@@ -843,3 +681,75 @@
   }
}
window.debouncer = new DebouncedActions();
// -----------------------------------------------------
// Scroll direction + scroll progress
// -----------------------------------------------------
const body = document.body;
const docEl = document.documentElement;
const progressBar = document.querySelector('.scroll-progress .bar');
let lastY = window.scrollY || docEl.scrollTop || 0;
let direction = -1;
let ticking = false;
let maxScroll = 0;
function updateMaxScroll() {
   maxScroll = Math.max(0, docEl.scrollHeight - window.innerHeight);
}
function updateScrollProgress(y) {
   if (!progressBar) return;
   const progress = maxScroll > 0 ? y / maxScroll : 0;
   const clamped = Math.max(0, Math.min(1, progress));
   progressBar.style.transform = `scaleX(${clamped})`;
}
function onScrollFrame() {
   const y = window.scrollY || docEl.scrollTop || 0;
   // Direction: 1 = down, -1 = up, keep existing if no movement
   if (y > lastY) {
      direction = 1;
   } else if (y < lastY) {
      direction = -1;
   }
   lastY = y;
   // Only add scroll-up when actually below top & moving up
   document.body.classList.toggle('scroll-up', direction < 0 && y > 0);
   // Update progress bar
   updateScrollProgress(y);
   ticking = false;
}
// Throttled scroll listener
window.addEventListener(
   'scroll',
   () => {
      if (!ticking) {
         ticking = true;
         requestAnimationFrame(onScrollFrame);
      }
   },
   { passive: true }
);
// Debounced resize to recalc scrollable height
window.addEventListener('resize', () => {
   window.debouncer.schedule('recalc-max-scroll', () => {
      updateMaxScroll();
      updateScrollProgress(window.scrollY || docEl.scrollTop || 0);
   }, 20);
});
// Initial setup
updateMaxScroll();
updateScrollProgress(lastY);