Files
TimeTrack/static/js/date-picker-enhancer.js
2025-07-13 10:52:20 +02:00

550 lines
22 KiB
JavaScript

// Date Picker Enhancer - Makes date/time inputs respect user preferences
(function() {
'use strict';
// Enhanced date input that shows user's preferred format
class EnhancedDateInput {
constructor(input) {
this.nativeInput = input;
this.userPrefs = DateFormatter.getUserPreferences();
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth <= 768;
// Only enhance date and datetime-local inputs
if (input.type !== 'date' && input.type !== 'datetime-local') {
return;
}
// Check if already enhanced
if (input.dataset.enhanced === 'true') {
return;
}
this.enhance();
}
enhance() {
// Skip if already in a hybrid date input structure
if (this.nativeInput.classList.contains('date-input-native') ||
this.nativeInput.closest('.hybrid-date-input')) {
return;
}
// Mark as enhanced
this.nativeInput.dataset.enhanced = 'true';
// Create wrapper
const wrapper = document.createElement('div');
wrapper.className = 'enhanced-date-wrapper';
// Create formatted text input
this.textInput = document.createElement('input');
this.textInput.type = 'text';
this.textInput.className = this.nativeInput.className + ' date-text-input';
this.textInput.placeholder = this.getPlaceholder();
this.textInput.required = this.nativeInput.required;
// Copy other attributes
if (this.nativeInput.id) {
this.textInput.id = this.nativeInput.id + '-text';
}
// Hide native input but keep it functional
this.nativeInput.style.position = 'absolute';
this.nativeInput.style.opacity = '0';
this.nativeInput.style.pointerEvents = 'none';
this.nativeInput.style.zIndex = '-1';
this.nativeInput.tabIndex = -1;
// Insert wrapper
this.nativeInput.parentNode.insertBefore(wrapper, this.nativeInput);
wrapper.appendChild(this.nativeInput);
wrapper.appendChild(this.textInput);
// Add calendar icon button
const calendarBtn = document.createElement('button');
calendarBtn.type = 'button';
calendarBtn.className = 'calendar-btn';
calendarBtn.innerHTML = '<i class="ti ti-calendar"></i>';
calendarBtn.title = 'Open date picker';
calendarBtn.setAttribute('aria-label', 'Open date picker');
wrapper.appendChild(calendarBtn);
// Add mobile hint
if (this.isMobile) {
this.textInput.placeholder = this.textInput.placeholder + ' (tap to select)';
}
// Set initial value if exists
if (this.nativeInput.value) {
this.updateTextInput();
}
// Event listeners
this.setupEventListeners(calendarBtn);
}
getPlaceholder() {
const format = this.userPrefs.dateFormat;
const isDateTime = this.nativeInput.type === 'datetime-local';
const datePlaceholder = {
'ISO': 'YYYY-MM-DD',
'US': 'MM/DD/YYYY',
'EU': 'DD/MM/YYYY',
'UK': 'DD/MM/YYYY',
'Readable': 'Jan 01, 2024',
'Full': 'January 01, 2024'
}[format] || 'YYYY-MM-DD';
if (isDateTime) {
const timePlaceholder = this.userPrefs.timeFormat24h ? 'HH:MM' : 'HH:MM AM';
return `${datePlaceholder} ${timePlaceholder}`;
}
return datePlaceholder;
}
setupEventListeners(calendarBtn) {
// On mobile, make the entire input area clickable for date picker
if (this.isMobile) {
this.textInput.addEventListener('focus', (e) => {
e.preventDefault();
// Trigger calendar button click
calendarBtn.click();
this.textInput.blur();
});
// Make text input read-only on mobile to prevent keyboard
this.textInput.readOnly = true;
this.textInput.style.cursor = 'pointer';
} else {
// Desktop behavior - allow typing
this.textInput.addEventListener('blur', () => {
this.parseUserInput();
});
// Allow Enter key to trigger parsing
this.textInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.parseUserInput();
}
});
}
// When native input changes, update text input
this.nativeInput.addEventListener('change', () => {
this.updateTextInput();
});
// Calendar button shows native picker
calendarBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// On mobile, we need different handling
if (window.innerWidth <= 768) {
// For mobile, overlay the native input over the button temporarily
this.nativeInput.style.position = 'absolute';
this.nativeInput.style.top = '0';
this.nativeInput.style.left = '0';
this.nativeInput.style.width = '100%';
this.nativeInput.style.height = '100%';
this.nativeInput.style.opacity = '0.01'; // Almost invisible but interactive
this.nativeInput.style.pointerEvents = 'auto';
this.nativeInput.style.zIndex = '100';
// Trigger the native picker
this.nativeInput.focus();
this.nativeInput.click();
// Some mobile browsers need showPicker()
if (this.nativeInput.showPicker) {
try {
this.nativeInput.showPicker();
} catch (e) {
// Fallback if showPicker is not supported
}
}
} else {
// Desktop behavior
this.nativeInput.style.opacity = '1';
this.nativeInput.style.pointerEvents = 'auto';
this.nativeInput.focus();
this.nativeInput.click();
}
// Hide native input after interaction
const hideNative = () => {
this.nativeInput.style.opacity = '0';
this.nativeInput.style.pointerEvents = 'none';
this.nativeInput.style.position = 'absolute';
this.nativeInput.style.zIndex = '-1';
this.nativeInput.removeEventListener('blur', hideNative);
this.nativeInput.removeEventListener('change', hideNative);
};
// Use timeout for mobile to ensure picker has opened
if (window.innerWidth <= 768) {
setTimeout(() => {
this.nativeInput.addEventListener('blur', hideNative);
this.nativeInput.addEventListener('change', hideNative);
}, 100);
} else {
this.nativeInput.addEventListener('blur', hideNative);
this.nativeInput.addEventListener('change', hideNative);
}
});
}
updateTextInput() {
const value = this.nativeInput.value;
if (!value) {
this.textInput.value = '';
return;
}
if (this.nativeInput.type === 'datetime-local') {
// Format datetime
const dt = new Date(value);
this.textInput.value = DateFormatter.formatDateTime(dt);
} else {
// Format date only
const dt = new Date(value + 'T00:00:00');
this.textInput.value = DateFormatter.formatDate(dt);
}
}
parseUserInput() {
const input = this.textInput.value.trim();
if (!input) {
this.nativeInput.value = '';
return;
}
try {
const parsed = this.parseDate(input);
if (parsed) {
// Convert to ISO format for native input
const year = parsed.getFullYear();
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
if (this.nativeInput.type === 'datetime-local') {
const hours = String(parsed.getHours()).padStart(2, '0');
const minutes = String(parsed.getMinutes()).padStart(2, '0');
this.nativeInput.value = `${year}-${month}-${day}T${hours}:${minutes}`;
} else {
this.nativeInput.value = `${year}-${month}-${day}`;
}
// Update text input with properly formatted value
this.updateTextInput();
// Trigger change event on native input
this.nativeInput.dispatchEvent(new Event('change', { bubbles: true }));
} else {
// Invalid input - restore previous value
this.updateTextInput();
this.textInput.classList.add('error');
setTimeout(() => this.textInput.classList.remove('error'), 2000);
}
} catch (e) {
console.error('Date parsing error:', e);
this.updateTextInput();
}
}
parseDate(input) {
// Remove extra spaces
input = input.replace(/\s+/g, ' ').trim();
// Try different parsing strategies based on user's format
const format = this.userPrefs.dateFormat;
let date = null;
// Extract time if present
let timeMatch = null;
let dateStr = input;
if (this.nativeInput.type === 'datetime-local') {
// Look for time patterns
const time24Match = input.match(/(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
const time12Match = input.match(/(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM|am|pm)$/i);
if (time12Match) {
timeMatch = time12Match;
dateStr = input.substring(0, input.lastIndexOf(time12Match[0])).trim();
} else if (time24Match) {
timeMatch = time24Match;
dateStr = input.substring(0, input.lastIndexOf(time24Match[0])).trim();
}
}
// Parse date part based on format
switch (format) {
case 'US': // MM/DD/YYYY
const usMatch = dateStr.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
if (usMatch) {
date = new Date(usMatch[3], usMatch[1] - 1, usMatch[2]);
}
break;
case 'EU':
case 'UK': // DD/MM/YYYY
const euMatch = dateStr.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
if (euMatch) {
date = new Date(euMatch[3], euMatch[2] - 1, euMatch[1]);
}
break;
case 'ISO': // YYYY-MM-DD
const isoMatch = dateStr.match(/^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/);
if (isoMatch) {
date = new Date(isoMatch[1], isoMatch[2] - 1, isoMatch[3]);
}
break;
case 'Readable': // Jan 01, 2024
case 'Full': // January 01, 2024
// Try to parse natural language dates
date = new Date(dateStr);
if (isNaN(date.getTime())) {
date = null;
}
break;
}
// If no specific format matched, try generic parsing
if (!date) {
date = new Date(dateStr);
if (isNaN(date.getTime())) {
return null;
}
}
// Add time if present
if (timeMatch && date) {
let hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const seconds = timeMatch[3] ? parseInt(timeMatch[3]) : 0;
// Handle 12-hour format
if (timeMatch[4]) {
const isPM = timeMatch[4].toUpperCase() === 'PM';
if (hours === 12 && !isPM) hours = 0;
else if (hours !== 12 && isPM) hours += 12;
}
date.setHours(hours, minutes, seconds);
}
return date;
}
}
// Enhanced time input for 12/24 hour format
class EnhancedTimeInput {
constructor(input) {
this.nativeInput = input;
this.userPrefs = DateFormatter.getUserPreferences();
if (input.type !== 'time' || input.dataset.enhanced === 'true') {
return;
}
// Only enhance if user prefers 12-hour format
if (this.userPrefs.timeFormat24h) {
return; // Native time input already uses 24-hour format
}
this.enhance();
}
enhance() {
// Similar enhancement for time inputs to show AM/PM
// This is simpler since time inputs are already somewhat flexible
// Add helper text
const helper = document.createElement('small');
helper.className = 'time-format-helper';
helper.textContent = '12-hour format (use AM/PM)';
this.nativeInput.parentNode.insertBefore(helper, this.nativeInput.nextSibling);
this.nativeInput.dataset.enhanced = 'true';
}
}
// Auto-enhance all date/time inputs
function enhanceAllInputs() {
// Enhance date and datetime-local inputs
document.querySelectorAll('input[type="date"], input[type="datetime-local"]').forEach(input => {
new EnhancedDateInput(input);
});
// Enhance time inputs
document.querySelectorAll('input[type="time"]').forEach(input => {
new EnhancedTimeInput(input);
});
}
// CSS injection for styling
function injectStyles() {
if (document.getElementById('date-picker-enhancer-styles')) return;
const styles = `
<style id="date-picker-enhancer-styles">
.enhanced-date-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 8px;
}
.date-text-input {
flex: 1;
}
.date-text-input.error {
border-color: var(--danger, #dc3545);
animation: shake 0.3s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.calendar-btn {
padding: 8px 12px;
border: 1px solid var(--border-color, #dee2e6);
background: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.calendar-btn:hover {
background: var(--bg-light, #f8f9fa);
border-color: var(--primary-color, #667eea);
}
.calendar-btn i {
font-size: 18px;
color: var(--primary-color, #667eea);
}
.time-format-helper {
display: block;
margin-top: 4px;
color: var(--text-muted, #6c757d);
font-size: 0.875em;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.enhanced-date-wrapper {
position: relative;
display: block;
width: 100%;
}
.calendar-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
padding: 12px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
background: white;
border: 1px solid var(--border-color, #dee2e6);
}
.date-text-input {
padding-right: 56px;
width: 100%;
min-height: 48px;
font-size: 16px; /* Prevent zoom on iOS */
-webkit-appearance: none;
appearance: none;
}
/* Read-only style on mobile */
.date-text-input[readonly] {
background-color: white;
cursor: pointer;
-webkit-tap-highlight-color: rgba(0,0,0,0.1);
}
/* Larger tap target for wrapper */
.enhanced-date-wrapper {
min-height: 48px;
}
/* Better touch targets */
.enhanced-date-wrapper input[type="date"],
.enhanced-date-wrapper input[type="datetime-local"] {
min-height: 48px;
width: 100%;
}
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
// Make sure DateFormatter is loaded
if (typeof DateFormatter === 'undefined') {
console.error('DateFormatter not found. Make sure date-formatter.js is loaded first.');
return;
}
injectStyles();
enhanceAllInputs();
// Re-enhance when new content is added dynamically
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1) { // Element node
if (node.matches && (node.matches('input[type="date"]') ||
node.matches('input[type="time"]') ||
node.matches('input[type="datetime-local"]'))) {
new EnhancedDateInput(node);
new EnhancedTimeInput(node);
}
// Check children
const inputs = node.querySelectorAll('input[type="date"], input[type="time"], input[type="datetime-local"]');
inputs.forEach(input => {
new EnhancedDateInput(input);
new EnhancedTimeInput(input);
});
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
});
// Expose for manual enhancement
window.DatePickerEnhancer = {
enhanceInput: function(input) {
if (input.type === 'date' || input.type === 'datetime-local') {
return new EnhancedDateInput(input);
} else if (input.type === 'time') {
return new EnhancedTimeInput(input);
}
},
enhanceAll: enhanceAllInputs
};
})();