Initial commit
Dieser Commit ist enthalten in:
615
frontend/js/utils.js
Normale Datei
615
frontend/js/utils.js
Normale Datei
@ -0,0 +1,615 @@
|
||||
/**
|
||||
* TASKMATE - Utility Functions
|
||||
* ============================
|
||||
*/
|
||||
|
||||
// Date Formatting
|
||||
export function formatDate(dateString, options = {}) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
|
||||
if (options.relative) {
|
||||
const diff = date - now;
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return 'Heute';
|
||||
if (days === 1) return 'Morgen';
|
||||
if (days === -1) return 'Gestern';
|
||||
if (days > 0 && days <= 7) return `In ${days} Tagen`;
|
||||
if (days < 0 && days >= -7) return `Vor ${Math.abs(days)} Tagen`;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: options.year ? 'numeric' : undefined,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
export function formatRelativeTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return 'Gerade eben';
|
||||
if (minutes < 60) return `Vor ${minutes} Min.`;
|
||||
if (hours < 24) return `Vor ${hours} Std.`;
|
||||
if (days < 7) return `Vor ${days} Tagen`;
|
||||
|
||||
return formatDate(dateString, { year: true });
|
||||
}
|
||||
|
||||
// Time Duration
|
||||
export function formatDuration(hours, minutes) {
|
||||
const parts = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
return parts.join(' ') || '0m';
|
||||
}
|
||||
|
||||
export function parseDuration(durationString) {
|
||||
const match = durationString.match(/(\d+)h\s*(\d+)?m?|(\d+)m/);
|
||||
if (!match) return { hours: 0, minutes: 0 };
|
||||
|
||||
if (match[1]) {
|
||||
return {
|
||||
hours: parseInt(match[1]) || 0,
|
||||
minutes: parseInt(match[2]) || 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hours: 0,
|
||||
minutes: parseInt(match[3]) || 0
|
||||
};
|
||||
}
|
||||
|
||||
// File Size
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + units[i];
|
||||
}
|
||||
|
||||
// String Utilities
|
||||
export function truncate(str, length = 50) {
|
||||
if (!str || str.length <= length) return str;
|
||||
return str.substring(0, length) + '...';
|
||||
}
|
||||
|
||||
export function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export function slugify(str) {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[äöüß]/g, match => ({
|
||||
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss'
|
||||
}[match]))
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export function getInitials(name) {
|
||||
if (!name) return '?';
|
||||
|
||||
return name
|
||||
.split(' ')
|
||||
.map(part => part.charAt(0).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// Color Utilities
|
||||
export function hexToRgba(hex, alpha = 1) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return hex;
|
||||
|
||||
return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`;
|
||||
}
|
||||
|
||||
export function getContrastColor(hexColor) {
|
||||
const rgb = parseInt(hexColor.slice(1), 16);
|
||||
const r = (rgb >> 16) & 0xff;
|
||||
const g = (rgb >> 8) & 0xff;
|
||||
const b = (rgb >> 0) & 0xff;
|
||||
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? '#000000' : '#FFFFFF';
|
||||
}
|
||||
|
||||
// DOM Utilities
|
||||
export function $(selector, context = document) {
|
||||
return context.querySelector(selector);
|
||||
}
|
||||
|
||||
export function $$(selector, context = document) {
|
||||
return Array.from(context.querySelectorAll(selector));
|
||||
}
|
||||
|
||||
export function createElement(tag, attributes = {}, children = []) {
|
||||
const element = document.createElement(tag);
|
||||
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
if (key === 'className') {
|
||||
element.className = value;
|
||||
} else if (key === 'dataset') {
|
||||
Object.entries(value).forEach(([dataKey, dataValue]) => {
|
||||
element.dataset[dataKey] = dataValue;
|
||||
});
|
||||
} else if (key === 'style' && typeof value === 'object') {
|
||||
Object.assign(element.style, value);
|
||||
} else if (key.startsWith('on') && typeof value === 'function') {
|
||||
element.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
} else if (key === 'checked' || key === 'disabled' || key === 'selected') {
|
||||
// Boolean properties must be set as properties, not attributes
|
||||
element[key] = value;
|
||||
} else {
|
||||
element.setAttribute(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
children.forEach(child => {
|
||||
if (typeof child === 'string') {
|
||||
element.appendChild(document.createTextNode(child));
|
||||
} else if (child instanceof Node) {
|
||||
element.appendChild(child);
|
||||
}
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
export function removeElement(element) {
|
||||
if (element && element.parentNode) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearElement(element) {
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Event Utilities
|
||||
export function debounce(func, wait = 300) {
|
||||
let timeout;
|
||||
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
export function throttle(func, limit = 100) {
|
||||
let inThrottle;
|
||||
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function onClickOutside(element, callback) {
|
||||
const handler = (event) => {
|
||||
if (!element.contains(event.target)) {
|
||||
callback(event);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handler);
|
||||
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}
|
||||
|
||||
// Array Utilities
|
||||
export function moveInArray(array, fromIndex, toIndex) {
|
||||
const element = array[fromIndex];
|
||||
array.splice(fromIndex, 1);
|
||||
array.splice(toIndex, 0, element);
|
||||
return array;
|
||||
}
|
||||
|
||||
export function groupBy(array, key) {
|
||||
return array.reduce((groups, item) => {
|
||||
const value = typeof key === 'function' ? key(item) : item[key];
|
||||
(groups[value] = groups[value] || []).push(item);
|
||||
return groups;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function sortBy(array, key, direction = 'asc') {
|
||||
const modifier = direction === 'desc' ? -1 : 1;
|
||||
|
||||
return [...array].sort((a, b) => {
|
||||
const aValue = typeof key === 'function' ? key(a) : a[key];
|
||||
const bValue = typeof key === 'function' ? key(b) : b[key];
|
||||
|
||||
if (aValue < bValue) return -1 * modifier;
|
||||
if (aValue > bValue) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Object Utilities
|
||||
export function deepClone(obj) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
export function deepMerge(target, source) {
|
||||
const output = { ...target };
|
||||
|
||||
Object.keys(source).forEach(key => {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
if (target[key] && typeof target[key] === 'object') {
|
||||
output[key] = deepMerge(target[key], source[key]);
|
||||
} else {
|
||||
output[key] = { ...source[key] };
|
||||
}
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// URL Utilities
|
||||
export function getUrlDomain(url) {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidUrl(string) {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileExtension(filename) {
|
||||
return filename.split('.').pop().toLowerCase();
|
||||
}
|
||||
|
||||
export function isImageFile(filename) {
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'];
|
||||
return imageExtensions.includes(getFileExtension(filename));
|
||||
}
|
||||
|
||||
// ID Generation
|
||||
export function generateId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
export function generateTempId() {
|
||||
return 'temp_' + generateId();
|
||||
}
|
||||
|
||||
// Local Storage
|
||||
export function getFromStorage(key, defaultValue = null) {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function setToStorage(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFromStorage(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority Stars
|
||||
export function getPriorityStars(priority) {
|
||||
const config = {
|
||||
high: { stars: '★★★', label: 'Hohe Priorität', colorClass: 'priority-high' },
|
||||
medium: { stars: '★★', label: 'Mittlere Priorität', colorClass: 'priority-medium' },
|
||||
low: { stars: '★', label: 'Niedrige Priorität', colorClass: 'priority-low' }
|
||||
};
|
||||
return config[priority] || config.medium;
|
||||
}
|
||||
|
||||
export function createPriorityElement(priority) {
|
||||
const config = getPriorityStars(priority);
|
||||
const span = document.createElement('span');
|
||||
span.className = `priority-stars ${config.colorClass}`;
|
||||
span.textContent = config.stars;
|
||||
span.title = config.label;
|
||||
return span;
|
||||
}
|
||||
|
||||
// Due Date Status
|
||||
export function getDueDateStatus(dueDate) {
|
||||
if (!dueDate) return null;
|
||||
|
||||
const due = new Date(dueDate);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
|
||||
|
||||
const diffDays = Math.ceil((dueDay - today) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return 'overdue';
|
||||
if (diffDays === 0) return 'today';
|
||||
if (diffDays <= 2) return 'soon';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
// Progress Calculation
|
||||
export function calculateProgress(subtasks) {
|
||||
if (!subtasks || subtasks.length === 0) return null;
|
||||
|
||||
const completed = subtasks.filter(st => st.completed).length;
|
||||
return {
|
||||
completed,
|
||||
total: subtasks.length,
|
||||
percentage: Math.round((completed / subtasks.length) * 100)
|
||||
};
|
||||
}
|
||||
|
||||
// Search/Filter Helpers
|
||||
export function matchesSearch(text, query) {
|
||||
if (!query) return true;
|
||||
if (!text) return false;
|
||||
|
||||
return text.toLowerCase().includes(query.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep search in task content - searches in title, description, subtasks, and labels
|
||||
* @param {Object} task - The task object to search in
|
||||
* @param {string} query - The search query
|
||||
* @returns {boolean} - True if query matches any content
|
||||
*/
|
||||
export function searchInTaskContent(task, query) {
|
||||
if (!query || !query.trim()) return true;
|
||||
|
||||
const searchQuery = query.toLowerCase().trim();
|
||||
|
||||
// Search in title
|
||||
if (task.title && task.title.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in description
|
||||
if (task.description && task.description.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in subtasks
|
||||
if (task.subtasks && Array.isArray(task.subtasks)) {
|
||||
for (const subtask of task.subtasks) {
|
||||
if (subtask.title && subtask.title.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in labels
|
||||
if (task.labels && Array.isArray(task.labels)) {
|
||||
for (const label of task.labels) {
|
||||
if (label.name && label.name.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in assigned user name
|
||||
if (task.assignedName && task.assignedName.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterTasks(tasks, filters, searchResultIds = [], columns = []) {
|
||||
// Hilfsfunktion: Prüfen ob Aufgabe in der letzten Spalte (erledigt) ist
|
||||
const isTaskCompleted = (task) => {
|
||||
if (!columns || columns.length === 0) return false;
|
||||
const lastColumnId = columns[columns.length - 1].id;
|
||||
return task.columnId === lastColumnId;
|
||||
};
|
||||
|
||||
return tasks.filter(task => {
|
||||
// Search query - use deep search
|
||||
// But allow tasks that were found by server search (for deep content like attachments)
|
||||
if (filters.search) {
|
||||
const isServerResult = searchResultIds.includes(task.id);
|
||||
const matchesClient = searchInTaskContent(task, filters.search);
|
||||
|
||||
if (!isServerResult && !matchesClient) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (filters.priority && filters.priority !== 'all') {
|
||||
if (task.priority !== filters.priority) return false;
|
||||
}
|
||||
|
||||
// Assignee filter
|
||||
if (filters.assignee && filters.assignee !== 'all') {
|
||||
if (task.assignedTo !== parseInt(filters.assignee)) return false;
|
||||
}
|
||||
|
||||
// Label filter
|
||||
if (filters.label && filters.label !== 'all') {
|
||||
const hasLabel = task.labels?.some(l => l.id === parseInt(filters.label));
|
||||
if (!hasLabel) return false;
|
||||
}
|
||||
|
||||
// Due date filter
|
||||
if (filters.dueDate && filters.dueDate !== 'all' && filters.dueDate !== '') {
|
||||
const status = getDueDateStatus(task.dueDate);
|
||||
|
||||
// Bei "überfällig" erledigte Aufgaben ausschließen
|
||||
if (filters.dueDate === 'overdue') {
|
||||
if (status !== 'overdue' || isTaskCompleted(task)) return false;
|
||||
}
|
||||
if (filters.dueDate === 'today' && status !== 'today') return false;
|
||||
if (filters.dueDate === 'week') {
|
||||
const due = new Date(task.dueDate);
|
||||
const weekFromNow = new Date();
|
||||
weekFromNow.setDate(weekFromNow.getDate() + 7);
|
||||
if (!task.dueDate || due > weekFromNow) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Clipboard
|
||||
export async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fallback
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
return true;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard Helpers
|
||||
export function getKeyCombo(event) {
|
||||
const parts = [];
|
||||
|
||||
if (event.ctrlKey || event.metaKey) parts.push('Ctrl');
|
||||
if (event.altKey) parts.push('Alt');
|
||||
if (event.shiftKey) parts.push('Shift');
|
||||
|
||||
if (event.key && !['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) {
|
||||
parts.push(event.key.toUpperCase());
|
||||
}
|
||||
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
// Focus Management
|
||||
export function trapFocus(element) {
|
||||
const focusableElements = element.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
const firstFocusable = focusableElements[0];
|
||||
const lastFocusable = focusableElements[focusableElements.length - 1];
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstFocusable) {
|
||||
lastFocusable.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastFocusable) {
|
||||
firstFocusable.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => element.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
|
||||
// Announcements for Screen Readers
|
||||
export function announce(message, priority = 'polite') {
|
||||
const announcer = document.getElementById('sr-announcer') || createAnnouncer();
|
||||
announcer.setAttribute('aria-live', priority);
|
||||
announcer.textContent = message;
|
||||
}
|
||||
|
||||
function createAnnouncer() {
|
||||
const announcer = document.createElement('div');
|
||||
announcer.id = 'sr-announcer';
|
||||
announcer.setAttribute('aria-live', 'polite');
|
||||
announcer.setAttribute('aria-atomic', 'true');
|
||||
announcer.className = 'sr-only';
|
||||
document.body.appendChild(announcer);
|
||||
return announcer;
|
||||
}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren