616 Zeilen
16 KiB
JavaScript
616 Zeilen
16 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|