From 7140aeba41f32d4e6be0b959b577300faaf029b4 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sun, 13 Jul 2025 10:52:20 +0200 Subject: [PATCH] Improve mobile UI/UX. --- app.py | 57 +++ static/css/mobile-bottom-nav.css | 125 ++++++ static/css/mobile-forms.css | 495 +++++++++++++++++++++ static/css/mobile-optimized.css | 567 ++++++++++++++++++++++++ static/js/date-formatter.js | 173 ++++++++ static/js/date-picker-enhancer.js | 550 +++++++++++++++++++++++ static/js/mobile-gestures.js | 403 +++++++++++++++++ static/js/mobile-performance.js | 323 ++++++++++++++ static/js/mobile-pull-refresh.js | 260 +++++++++++ static/js/mobile-tables.js | 355 +++++++++++++++ static/js/sidebar.js | 8 +- static/manifest.json | 101 +++++ templates/_time_tracking_interface.html | 4 +- templates/admin_company.html | 2 +- templates/admin_projects.html | 4 +- templates/config.html | 12 +- templates/index.html | 59 +++ templates/layout.html | 91 +++- templates/note_view.html | 37 +- templates/profile.html | 2 +- templates/timetrack.html | 23 +- 21 files changed, 3604 insertions(+), 47 deletions(-) create mode 100644 static/css/mobile-bottom-nav.css create mode 100644 static/css/mobile-forms.css create mode 100644 static/css/mobile-optimized.css create mode 100644 static/js/date-formatter.js create mode 100644 static/js/date-picker-enhancer.js create mode 100644 static/js/mobile-gestures.js create mode 100644 static/js/mobile-performance.js create mode 100644 static/js/mobile-pull-refresh.js create mode 100644 static/js/mobile-tables.js create mode 100644 static/manifest.json diff --git a/app.py b/app.py index 8d1e146..d1511cb 100644 --- a/app.py +++ b/app.py @@ -290,6 +290,63 @@ def setup(): """Company setup route - delegates to imported function""" return company_setup() +@app.route('/robots.txt') +def robots_txt(): + """Generate robots.txt for search engines""" + lines = [ + "User-agent: *", + "Allow: /", + "Disallow: /admin/", + "Disallow: /api/", + "Disallow: /export/", + "Disallow: /profile/", + "Disallow: /config/", + "Disallow: /teams/", + "Disallow: /projects/", + "Disallow: /logout", + f"Sitemap: {request.host_url}sitemap.xml", + "", + "# TimeTrack - Open Source Time Tracking Software", + "# https://github.com/nullmedium/TimeTrack" + ] + return Response('\n'.join(lines), mimetype='text/plain') + +@app.route('/sitemap.xml') +def sitemap_xml(): + """Generate XML sitemap for search engines""" + pages = [] + + # Static pages accessible without login + static_pages = [ + {'loc': '/', 'priority': '1.0', 'changefreq': 'daily'}, + {'loc': '/login', 'priority': '0.8', 'changefreq': 'monthly'}, + {'loc': '/register', 'priority': '0.9', 'changefreq': 'monthly'}, + {'loc': '/forgot_password', 'priority': '0.5', 'changefreq': 'monthly'}, + ] + + for page in static_pages: + pages.append({ + 'loc': request.host_url[:-1] + page['loc'], + 'lastmod': datetime.now().strftime('%Y-%m-%d'), + 'priority': page['priority'], + 'changefreq': page['changefreq'] + }) + + sitemap_xml = '\n' + sitemap_xml += '\n' + + for page in pages: + sitemap_xml += ' \n' + sitemap_xml += f' {page["loc"]}\n' + sitemap_xml += f' {page["lastmod"]}\n' + sitemap_xml += f' {page["changefreq"]}\n' + sitemap_xml += f' {page["priority"]}\n' + sitemap_xml += ' \n' + + sitemap_xml += '' + + return Response(sitemap_xml, mimetype='application/xml') + @app.route('/') def home(): if g.user: diff --git a/static/css/mobile-bottom-nav.css b/static/css/mobile-bottom-nav.css new file mode 100644 index 0000000..c53ed62 --- /dev/null +++ b/static/css/mobile-bottom-nav.css @@ -0,0 +1,125 @@ +/* Mobile Bottom Navigation Bar */ +@media (max-width: 768px) { + /* Bottom Navigation Container */ + .mobile-bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: white; + border-top: 1px solid var(--border-color, #e0e0e0); + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); + z-index: 100; + display: flex; + justify-content: space-around; + align-items: center; + padding: 8px 0; + padding-bottom: calc(8px + env(safe-area-inset-bottom, 0)); + } + + /* Hide on desktop */ + @media (min-width: 769px) { + .mobile-bottom-nav { + display: none; + } + } + + /* Navigation Items */ + .bottom-nav-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 48px; + padding: 4px; + text-decoration: none; + color: var(--text-secondary, #666); + transition: all 0.2s ease; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + position: relative; + } + + /* Icon styling */ + .bottom-nav-item i { + font-size: 24px; + margin-bottom: 2px; + transition: transform 0.2s ease; + } + + /* Label styling */ + .bottom-nav-item span { + font-size: 11px; + font-weight: 500; + } + + /* Active state */ + .bottom-nav-item.active { + color: var(--primary-color, #667eea); + } + + .bottom-nav-item.active i { + transform: scale(1.1); + } + + /* Touch feedback */ + .bottom-nav-item:active { + opacity: 0.7; + transform: scale(0.95); + } + + /* Center FAB-style time tracking button */ + .bottom-nav-item.nav-fab { + position: relative; + top: -10px; + } + + .bottom-nav-item.nav-fab .fab-button { + width: 56px; + height: 56px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + margin-bottom: 4px; + } + + .bottom-nav-item.nav-fab .fab-button i { + color: white; + font-size: 28px; + margin: 0; + } + + .bottom-nav-item.nav-fab span { + position: absolute; + bottom: -4px; + white-space: nowrap; + } + + /* Notification badge */ + .nav-badge { + position: absolute; + top: 4px; + right: calc(50% - 16px); + background: #ff3b30; + color: white; + font-size: 10px; + font-weight: bold; + padding: 2px 4px; + border-radius: 10px; + min-width: 16px; + text-align: center; + } + + /* Adjust main content to avoid overlap */ + .has-bottom-nav .content { + padding-bottom: calc(80px + env(safe-area-inset-bottom, 0)) !important; + } + + /* Keep sidebar for hamburger menu functionality */ + /* Removed the rule that was hiding sidebar */ +} \ No newline at end of file diff --git a/static/css/mobile-forms.css b/static/css/mobile-forms.css new file mode 100644 index 0000000..f044881 --- /dev/null +++ b/static/css/mobile-forms.css @@ -0,0 +1,495 @@ +/* Mobile Form Enhancements for TimeTrack */ +/* This file contains mobile-specific form optimizations */ + +/* ===== Form Container Improvements ===== */ +@media (max-width: 768px) { + /* Form wrapper adjustments */ + .form-container, + .auth-container, + .profile-container { + padding: var(--mobile-edge-padding); + width: 100%; + max-width: 100%; + } + + /* Form sections with better spacing */ + .form-section { + margin-bottom: 24px; + padding: 16px; + background: var(--bg-light); + border-radius: 12px; + } + + .form-section-title { + font-size: 1.125rem; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); + } +} + +/* ===== Enhanced Input Fields ===== */ +@media (max-width: 768px) { + /* Base input improvements */ + input[type="text"], + input[type="email"], + input[type="password"], + input[type="number"], + input[type="tel"], + input[type="date"], + input[type="time"], + input[type="datetime-local"], + input[type="search"], + input[type="url"], + select, + textarea { + width: 100%; + min-height: var(--mobile-input-height); + padding: 14px 16px; + font-size: 16px; /* Prevents zoom on iOS */ + border: 2px solid var(--border-color); + transition: border-color 0.2s ease; + -webkit-appearance: none; + border-radius: 8px; + background-color: var(--bg-light); + } + + /* Focus states with better visibility */ + input:focus, + select:focus, + textarea:focus { + border-color: var(--primary-color); + outline: none; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); + } + + /* Textarea specific */ + textarea { + min-height: 100px; + resize: vertical; + } + + /* Select dropdown improvements */ + select { + background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23667eea' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 16px center; + padding-right: 40px; + } + + /* Checkbox and radio improvements */ + input[type="checkbox"], + input[type="radio"] { + width: 24px; + height: 24px; + margin-right: 12px; + flex-shrink: 0; + cursor: pointer; + } + + /* Label improvements for checkboxes/radios */ + .checkbox-wrapper, + .radio-wrapper { + display: flex; + align-items: center; + min-height: var(--mobile-touch-target); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + + .checkbox-wrapper label, + .radio-wrapper label { + cursor: pointer; + flex: 1; + } +} + +/* ===== Form Labels and Helpers ===== */ +@media (max-width: 768px) { + /* Label styling */ + label, + .form-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--text-secondary); + font-size: 14px; + } + + /* Required field indicator */ + label.required::after, + .form-label.required::after { + content: " *"; + color: var(--danger); + } + + /* Help text */ + .form-help, + .help-text { + display: block; + margin-top: 4px; + font-size: 13px; + color: var(--text-muted); + } + + /* Error messages */ + .form-error, + .error-message { + display: block; + margin-top: 4px; + font-size: 13px; + color: var(--danger); + } + + /* Input with error state */ + .has-error input, + .has-error select, + .has-error textarea { + border-color: var(--danger); + } +} + +/* ===== Form Groups and Layouts ===== */ +@media (max-width: 768px) { + /* Form group spacing */ + .form-group, + .mb-3 { + margin-bottom: 20px; + } + + /* Stack form columns on mobile */ + .form-row, + .row { + display: block; + } + + .form-row > *, + .row > * { + width: 100%; + margin-bottom: 20px; + } + + .form-row > *:last-child, + .row > *:last-child { + margin-bottom: 0; + } + + /* Inline form groups */ + .form-inline { + display: block; + } + + .form-inline > * { + width: 100%; + margin-bottom: 12px; + } +} + +/* ===== Button Groups and Actions ===== */ +@media (max-width: 768px) { + /* Form action buttons */ + .form-actions, + .button-group { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 24px; + } + + .form-actions .btn, + .button-group .btn { + width: 100%; + justify-content: center; + } + + /* Primary action emphasis */ + .form-actions .btn-primary, + .button-group .btn-primary { + order: -1; /* Move primary button to top */ + } + + /* Horizontal button groups on larger mobiles */ + @media (min-width: 480px) { + .form-actions.horizontal, + .button-group.horizontal { + flex-direction: row; + } + + .form-actions.horizontal .btn, + .button-group.horizontal .btn { + flex: 1; + } + } +} + +/* ===== File Upload Enhancements ===== */ +@media (max-width: 768px) { + /* File input styling */ + input[type="file"] { + width: 100%; + padding: 12px; + border: 2px dashed var(--border-color); + border-radius: 8px; + background: var(--bg-light); + } + + /* Custom file upload area */ + .file-upload-area { + position: relative; + padding: 32px 16px; + border: 2px dashed var(--primary-color); + border-radius: 12px; + text-align: center; + background: rgba(102, 126, 234, 0.05); + cursor: pointer; + transition: all 0.2s ease; + } + + .file-upload-area:hover, + .file-upload-area.drag-over { + background: rgba(102, 126, 234, 0.1); + border-color: var(--primary-color); + } + + .file-upload-area input[type="file"] { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + } +} + +/* ===== Search and Filter Forms ===== */ +@media (max-width: 768px) { + /* Search input with icon */ + .search-wrapper { + position: relative; + } + + .search-wrapper input[type="search"] { + padding-left: 44px; + } + + .search-wrapper .search-icon { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + } + + /* Filter form improvements */ + .filter-form { + background: var(--bg-light); + padding: 16px; + border-radius: 12px; + margin-bottom: 20px; + } + + .filter-toggle { + display: block; + width: 100%; + padding: 12px; + margin-bottom: 16px; + background: var(--primary-color); + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + } + + .filter-content { + display: none; + } + + .filter-content.show { + display: block; + } +} + +/* ===== Keyboard and Focus Management ===== */ +@media (max-width: 768px) { + /* Adjust layout when keyboard is visible */ + .keyboard-visible .form-actions { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--bg-light); + padding: 16px; + box-shadow: 0 -2px 10px rgba(0,0,0,0.1); + z-index: 100; + } + + /* Tab focus indicators */ + .form-tab-nav { + display: flex; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin-bottom: 20px; + border-bottom: 1px solid var(--border-color); + } + + .form-tab-nav button { + flex-shrink: 0; + padding: 12px 20px; + border: none; + background: none; + font-size: 16px; + color: var(--text-secondary); + border-bottom: 2px solid transparent; + transition: all 0.2s ease; + } + + .form-tab-nav button.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); + } +} + +/* ===== Time Entry Form Specific ===== */ +@media (max-width: 768px) { + /* Timer display adjustments */ + .timer-form { + text-align: center; + } + + .timer-display { + font-size: clamp(2.5rem, 10vw, 4rem); + margin: 20px 0; + } + + /* Project/task selectors */ + .time-entry-selectors { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 20px; + } + + /* Timer controls */ + .timer-controls { + display: flex; + gap: 12px; + justify-content: center; + } + + .timer-controls .btn { + flex: 1; + max-width: 160px; + } +} + +/* ===== Login/Register Form Specific ===== */ +@media (max-width: 768px) { + /* Auth forms centering */ + .auth-wrapper { + min-height: 100vh; + display: flex; + align-items: center; + padding: 20px; + } + + .auth-form { + width: 100%; + max-width: 400px; + margin: 0 auto; + } + + /* Registration type selector */ + .registration-type-selector { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 24px; + } + + .registration-type-option { + padding: 16px; + border: 2px solid var(--border-color); + border-radius: 8px; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + } + + .registration-type-option.active { + border-color: var(--primary-color); + background: rgba(102, 126, 234, 0.05); + } +} + +/* ===== Progressive Enhancement ===== */ +@media (max-width: 768px) { + /* Collapsible form sections */ + .collapsible-section { + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 16px; + overflow: hidden; + } + + .collapsible-header { + padding: 16px; + background: var(--bg-light); + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + -webkit-tap-highlight-color: transparent; + } + + .collapsible-header::after { + content: "▼"; + transition: transform 0.2s ease; + } + + .collapsible-section.collapsed .collapsible-header::after { + transform: rotate(-90deg); + } + + .collapsible-content { + padding: 16px; + display: block; + } + + .collapsible-section.collapsed .collapsible-content { + display: none; + } +} + +/* ===== Accessibility Improvements ===== */ +@media (max-width: 768px) { + /* Skip to form content link */ + .skip-to-form { + position: absolute; + top: -40px; + left: 0; + background: var(--primary-color); + color: white; + padding: 8px 16px; + text-decoration: none; + border-radius: 0 0 8px 0; + z-index: 1000; + } + + .skip-to-form:focus { + top: 0; + } + + /* Focus visible improvements */ + *:focus-visible { + outline: 3px solid var(--primary-color); + outline-offset: 2px; + } + + /* Reduced motion support */ + @media (prefers-reduced-motion: reduce) { + * { + transition-duration: 0.01ms !important; + } + } +} \ No newline at end of file diff --git a/static/css/mobile-optimized.css b/static/css/mobile-optimized.css new file mode 100644 index 0000000..add5556 --- /dev/null +++ b/static/css/mobile-optimized.css @@ -0,0 +1,567 @@ +/* Mobile-Optimized CSS for TimeTrack */ +/* This file contains mobile-first responsive styles to improve the mobile experience */ + +/* ===== CSS Variables for Mobile ===== */ +:root { + /* Touch-friendly sizes */ + --mobile-touch-target: 44px; + --mobile-input-height: 48px; + --mobile-button-padding: 12px 16px; + + /* Spacing */ + --mobile-edge-padding: 16px; + --mobile-content-padding: 20px; + + /* Typography scaling */ + --mobile-base-font: 16px; + --mobile-small-font: 14px; + + /* Z-index layers */ + --z-mobile-header: 100; + --z-mobile-nav: 300; + --z-mobile-overlay: 290; + --z-bottom-nav: 100; +} + +/* ===== Base Mobile Styles ===== */ +@media (max-width: 768px) { + /* Prevent horizontal scroll */ + html, body { + overflow-x: hidden; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + } + + /* Better viewport handling for mobile browsers */ + .sidebar { + height: 100dvh !important; /* Use dvh for better mobile support */ + max-height: -webkit-fill-available; + } + + /* Improved mobile header */ + .mobile-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: var(--z-mobile-header); + background: #ffffff !important; /* Override green background */ + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + padding: 8px var(--mobile-edge-padding); + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid rgba(0,0,0,0.08); + } + + /* Ensure content doesn't hide behind fixed header */ + .content { + padding-top: calc(56px + var(--mobile-content-padding)) !important; + padding-left: var(--mobile-edge-padding) !important; + padding-right: var(--mobile-edge-padding) !important; + padding-bottom: env(safe-area-inset-bottom, 20px); + } + + /* ===== Typography Optimization ===== */ + body { + font-size: var(--mobile-base-font); + line-height: 1.6; + } + + h1 { + font-size: clamp(1.5rem, 5vw, 2rem); + margin-bottom: 1rem; + } + + h2 { + font-size: clamp(1.25rem, 4vw, 1.75rem); + margin-bottom: 0.875rem; + } + + h3 { + font-size: clamp(1.125rem, 3.5vw, 1.5rem); + margin-bottom: 0.75rem; + } + + h4, h5, h6 { + font-size: clamp(1rem, 3vw, 1.25rem); + margin-bottom: 0.625rem; + } + + /* ===== Mobile Navigation Improvements ===== */ + .sidebar { + transform: translateX(-100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + width: 85vw; + max-width: 320px; + border-radius: 0; + box-shadow: 2px 0 20px rgba(0,0,0,0.15); + z-index: var(--z-mobile-nav) !important; + position: fixed !important; + } + + .sidebar.active { + transform: translateX(0); + } + + /* Mobile navigation overlay */ + .mobile-nav-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; + z-index: var(--z-mobile-overlay); + -webkit-tap-highlight-color: transparent; + } + + .mobile-nav-overlay.active { + opacity: 1; + visibility: visible; + } + + /* Mobile menu toggle button */ + .mobile-menu-toggle, + .mobile-nav-toggle, + #mobile-nav-toggle { + min-width: var(--mobile-touch-target); + min-height: var(--mobile-touch-target); + display: flex !important; + align-items: center; + justify-content: center; + -webkit-tap-highlight-color: transparent; + background: none !important; + border: none !important; + cursor: pointer; + padding: 0; + width: 44px; + height: 44px; + flex-direction: column; + gap: 4px; + position: relative; + } + + /* Hamburger menu lines - override white color with higher specificity */ + .mobile-menu-toggle span, + .mobile-nav-toggle span, + #mobile-nav-toggle span, + .mobile-header .mobile-nav-toggle span { + display: block !important; + height: 3px !important; + width: 24px !important; + background-color: var(--primary-color, #667eea) !important; + border-radius: 2px !important; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + opacity: 1 !important; + visibility: visible !important; + } + + /* Hover state */ + .mobile-menu-toggle:hover span, + .mobile-nav-toggle:hover span, + #mobile-nav-toggle:hover span { + background-color: var(--primary-gradient-end, #764ba2) !important; + } + + /* Active/open state animations */ + .mobile-menu-toggle.active span:nth-child(1), + .mobile-nav-toggle.active span:nth-child(1), + #mobile-nav-toggle.active span:nth-child(1) { + transform: rotate(45deg) translate(6px, 6px); + } + + .mobile-menu-toggle.active span:nth-child(2), + .mobile-nav-toggle.active span:nth-child(2), + #mobile-nav-toggle.active span:nth-child(2) { + opacity: 0 !important; + transform: scaleX(0); + } + + .mobile-menu-toggle.active span:nth-child(3), + .mobile-nav-toggle.active span:nth-child(3), + #mobile-nav-toggle.active span:nth-child(3) { + transform: rotate(-45deg) translate(6px, -6px); + } + + /* Fallback: If spans still not visible, add pseudo-element hamburger */ + #mobile-nav-toggle::before { + content: "☰"; + font-size: 24px; + color: var(--primary-color, #667eea); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: none; + } + + /* Show fallback if spans are hidden */ + #mobile-nav-toggle:not(.active) span:first-child:not(:visible) ~ ::before { + display: block; + } + + /* Mobile brand/logo styling */ + .mobile-nav-brand { + font-size: 18px; + font-weight: 600; + color: var(--primary-color, #667eea); + } + + .mobile-nav-brand a { + color: inherit; + text-decoration: none; + display: flex; + align-items: center; + gap: 8px; + } + + .mobile-logo { + max-height: 32px; + width: auto; + } + + /* ===== Touch-Friendly Form Elements ===== */ + input[type="text"], + input[type="email"], + input[type="password"], + input[type="number"], + input[type="tel"], + input[type="date"], + input[type="time"], + input[type="datetime-local"], + select, + textarea { + min-height: var(--mobile-input-height); + font-size: var(--mobile-base-font); + padding: 12px; + border-radius: 8px; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + } + + /* Prevent zoom on input focus */ + input[type="text"]:focus, + input[type="email"]:focus, + input[type="password"]:focus, + input[type="number"]:focus, + input[type="tel"]:focus, + select:focus, + textarea:focus { + font-size: var(--mobile-base-font); + } + + /* Touch-friendly buttons */ + .btn, + button, + .button, + input[type="submit"], + input[type="button"] { + min-height: var(--mobile-touch-target); + padding: var(--mobile-button-padding); + font-size: var(--mobile-base-font); + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + } + + /* Icon buttons need proper sizing */ + .btn-icon, + .icon-button { + min-width: var(--mobile-touch-target); + min-height: var(--mobile-touch-target); + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + } + + /* ===== Table Responsiveness ===== */ + .table-responsive { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin-left: calc(-1 * var(--mobile-edge-padding)); + margin-right: calc(-1 * var(--mobile-edge-padding)); + padding-left: var(--mobile-edge-padding); + padding-right: var(--mobile-edge-padding); + } + + /* Visual scroll indicator for tables */ + .table-responsive::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 30px; + background: linear-gradient(to right, transparent, rgba(255,255,255,0.8)); + pointer-events: none; + opacity: 0; + transition: opacity 0.3s; + } + + .table-responsive.scrollable::after { + opacity: 1; + } + + /* Compact table styling */ + .data-table { + font-size: var(--mobile-small-font); + } + + .data-table th, + .data-table td { + padding: 8px 12px; + white-space: nowrap; + } + + /* Card-based layout for complex data */ + .mobile-card-view { + display: none; + } + + @media (max-width: 576px) { + .hide-on-mobile { + display: none !important; + } + + .show-on-mobile { + display: block !important; + } + + .mobile-card-view { + display: block; + } + + .desktop-table-view { + display: none; + } + } + + /* ===== Modal Improvements ===== */ + .modal { + margin: 0; + max-height: 100dvh; + height: auto; + } + + .modal-dialog { + margin: 0; + max-width: 100%; + height: 100dvh; + display: flex; + align-items: flex-end; + } + + .modal-content { + border-radius: 16px 16px 0 0; + max-height: 90dvh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + margin-top: auto; + } + + /* Account for mobile keyboard */ + .modal-open .modal-content { + padding-bottom: env(keyboard-inset-height, 0); + } + + /* ===== Card and Container Adjustments ===== */ + .card { + border-radius: 12px; + padding: var(--mobile-content-padding); + margin-bottom: 16px; + } + + .card-header { + margin: calc(-1 * var(--mobile-content-padding)); + margin-bottom: var(--mobile-content-padding); + padding: 16px var(--mobile-content-padding); + } + + /* ===== Mobile-Specific Components ===== */ + /* Bottom action bar for primary actions */ + .mobile-action-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--bg-light); + border-top: 1px solid var(--border-color); + padding: 12px var(--mobile-edge-padding); + padding-bottom: calc(12px + env(safe-area-inset-bottom, 0)); + display: flex; + gap: 12px; + z-index: 90; + } + + .mobile-action-bar .btn { + flex: 1; + } + + /* Content adjustment when action bar is present */ + .has-mobile-action-bar .content { + padding-bottom: calc(80px + env(safe-area-inset-bottom, 0)) !important; + } + + /* ===== Time Tracking Specific ===== */ + /* Fix page header on mobile */ + .page-header { + padding: 1.5rem var(--mobile-edge-padding) !important; + } + + .header-content { + flex-direction: column; + gap: 1rem; + } + + .header-left { + text-align: center; + } + + .page-title { + font-size: clamp(1.5rem, 5vw, 2rem); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + + .page-subtitle { + font-size: 0.875rem; + opacity: 0.9; + } + + /* Header action buttons */ + .header-actions { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + } + + .header-actions .btn { + width: 100%; + justify-content: center; + padding: 0.75rem 1rem; + font-size: 0.875rem; + } + + /* For slightly larger phones, show buttons side by side */ + @media (min-width: 400px) { + .header-actions { + flex-direction: row; + } + + .header-actions .btn { + flex: 1; + } + } + + /* Timer display and controls */ + .timer-display, + #timer { + font-size: clamp(1.5rem, 5vw, 2rem) !important; + } + + .timer-controls { + gap: 12px; + flex-wrap: wrap; + justify-content: center; + } + + .timer-controls .btn { + min-width: 120px; + } + + /* Timer card adjustments */ + .timer-card { + padding: 1.5rem var(--mobile-edge-padding); + } + + /* Stats cards in grid */ + .stats-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + @media (min-width: 480px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + } + + /* ===== Performance Optimizations ===== */ + /* Reduce motion for users who prefer it */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } + + /* ===== Landscape Orientation Handling ===== */ + @media (max-height: 500px) and (orientation: landscape) { + .mobile-header { + height: 48px; + } + + .content { + padding-top: calc(48px + 12px) !important; + } + + .modal-content { + max-height: 100dvh; + border-radius: 0; + } + } +} + +/* ===== Mobile Utilities ===== */ +@media (max-width: 768px) { + /* Spacing utilities */ + .mobile-mt-1 { margin-top: 8px !important; } + .mobile-mt-2 { margin-top: 16px !important; } + .mobile-mt-3 { margin-top: 24px !important; } + .mobile-mb-1 { margin-bottom: 8px !important; } + .mobile-mb-2 { margin-bottom: 16px !important; } + .mobile-mb-3 { margin-bottom: 24px !important; } + + /* Text alignment */ + .mobile-text-center { text-align: center !important; } + .mobile-text-left { text-align: left !important; } + .mobile-text-right { text-align: right !important; } + + /* Flexbox utilities */ + .mobile-flex-column { flex-direction: column !important; } + .mobile-flex-wrap { flex-wrap: wrap !important; } + + /* Full width on mobile */ + .mobile-full-width { + width: 100% !important; + max-width: 100% !important; + } +} + +/* ===== Improved Focus States for Accessibility ===== */ +@media (max-width: 768px) { + *:focus { + outline: 3px solid var(--primary-color); + outline-offset: 2px; + } + + input:focus, + select:focus, + textarea:focus { + outline-offset: 0; + border-color: var(--primary-color); + } +} \ No newline at end of file diff --git a/static/js/date-formatter.js b/static/js/date-formatter.js new file mode 100644 index 0000000..14afcff --- /dev/null +++ b/static/js/date-formatter.js @@ -0,0 +1,173 @@ +// Date and Time Formatting Utility +// This file provides client-side date/time formatting that matches user preferences + +(function() { + 'use strict'; + + // Get user preferences from data attributes or localStorage + function getUserPreferences() { + // Check if preferences are stored in the DOM + const prefsElement = document.getElementById('user-preferences'); + if (prefsElement) { + return { + dateFormat: prefsElement.dataset.dateFormat || 'ISO', + timeFormat24h: prefsElement.dataset.timeFormat24h === 'true' + }; + } + + // Fallback to localStorage + return { + dateFormat: localStorage.getItem('dateFormat') || 'ISO', + timeFormat24h: localStorage.getItem('timeFormat24h') === 'true' + }; + } + + // Format date according to user preference + function formatDate(date) { + if (!date) return ''; + + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return ''; + + const prefs = getUserPreferences(); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + + switch (prefs.dateFormat) { + case 'US': + return `${month}/${day}/${year}`; + case 'EU': + case 'UK': + return `${day}/${month}/${year}`; + case 'Readable': + return d.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + case 'Full': + return d.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + case 'ISO': + default: + return `${year}-${month}-${day}`; + } + } + + // Format time according to user preference + function formatTime(date, includeSeconds = true) { + if (!date) return ''; + + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return ''; + + const prefs = getUserPreferences(); + const hours = d.getHours(); + const minutes = String(d.getMinutes()).padStart(2, '0'); + const seconds = String(d.getSeconds()).padStart(2, '0'); + + if (prefs.timeFormat24h) { + const h24 = String(hours).padStart(2, '0'); + return includeSeconds ? `${h24}:${minutes}:${seconds}` : `${h24}:${minutes}`; + } else { + const h12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + const ampm = hours >= 12 ? 'PM' : 'AM'; + const timeStr = includeSeconds ? + `${h12}:${minutes}:${seconds}` : + `${h12}:${minutes}`; + return `${timeStr} ${ampm}`; + } + } + + // Format datetime according to user preference + function formatDateTime(date) { + if (!date) return ''; + + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return ''; + + return `${formatDate(d)} ${formatTime(d)}`; + } + + // Format duration (seconds to HH:MM:SS) + function formatDuration(seconds) { + if (seconds == null || seconds < 0) return '00:00:00'; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + return [hours, minutes, secs] + .map(v => String(v).padStart(2, '0')) + .join(':'); + } + + // Update all elements with data-format attributes + function updateFormattedDates() { + // Update date elements + document.querySelectorAll('[data-format-date]').forEach(el => { + const dateStr = el.dataset.formatDate; + if (dateStr) { + el.textContent = formatDate(dateStr); + } + }); + + // Update time elements + document.querySelectorAll('[data-format-time]').forEach(el => { + const timeStr = el.dataset.formatTime; + const includeSeconds = el.dataset.includeSeconds !== 'false'; + if (timeStr) { + el.textContent = formatTime(timeStr, includeSeconds); + } + }); + + // Update datetime elements + document.querySelectorAll('[data-format-datetime]').forEach(el => { + const datetimeStr = el.dataset.formatDatetime; + if (datetimeStr) { + el.textContent = formatDateTime(datetimeStr); + } + }); + + // Update duration elements + document.querySelectorAll('[data-format-duration]').forEach(el => { + const seconds = parseInt(el.dataset.formatDuration); + if (!isNaN(seconds)) { + el.textContent = formatDuration(seconds); + } + }); + } + + // Store preferences in localStorage when they change + function storePreferences(dateFormat, timeFormat24h) { + localStorage.setItem('dateFormat', dateFormat); + localStorage.setItem('timeFormat24h', timeFormat24h); + } + + // Initialize on DOM ready + document.addEventListener('DOMContentLoaded', function() { + updateFormattedDates(); + + // Listen for preference changes + window.addEventListener('preferenceChanged', function(e) { + if (e.detail) { + storePreferences(e.detail.dateFormat, e.detail.timeFormat24h); + updateFormattedDates(); + } + }); + }); + + // Expose functions globally + window.DateFormatter = { + formatDate: formatDate, + formatTime: formatTime, + formatDateTime: formatDateTime, + formatDuration: formatDuration, + updateFormattedDates: updateFormattedDates, + getUserPreferences: getUserPreferences + }; +})(); \ No newline at end of file diff --git a/static/js/date-picker-enhancer.js b/static/js/date-picker-enhancer.js new file mode 100644 index 0000000..a3b2cf9 --- /dev/null +++ b/static/js/date-picker-enhancer.js @@ -0,0 +1,550 @@ +// 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 = ''; + 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 = ` + + `; + + 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 + }; +})(); \ No newline at end of file diff --git a/static/js/mobile-gestures.js b/static/js/mobile-gestures.js new file mode 100644 index 0000000..00edafe --- /dev/null +++ b/static/js/mobile-gestures.js @@ -0,0 +1,403 @@ +// Enhanced Mobile Gesture Support +(function() { + 'use strict'; + + class MobileGestures { + constructor() { + this.touchStartX = 0; + this.touchStartY = 0; + this.touchEndX = 0; + this.touchEndY = 0; + this.longPressTimer = null; + + this.init(); + } + + init() { + // Only initialize on mobile + if (!this.isMobile()) return; + + // Add swipe to navigate back + this.initSwipeBack(); + + // Add long press for context menus + this.initLongPress(); + + // Add swipe actions for list items + this.initSwipeActions(); + + // Add pinch to zoom for images/charts + this.initPinchZoom(); + } + + isMobile() { + return window.innerWidth <= 768 || 'ontouchstart' in window; + } + + // Swipe from left edge to go back + initSwipeBack() { + let startX = 0; + let startY = 0; + let startTime = 0; + + document.addEventListener('touchstart', (e) => { + const touch = e.touches[0]; + startX = touch.clientX; + startY = touch.clientY; + startTime = Date.now(); + + // Only track if starting from left edge + if (startX > 30) return; + + // Add visual indicator + this.showSwipeIndicator(); + }, { passive: true }); + + document.addEventListener('touchmove', (e) => { + if (startX > 30) return; + + const touch = e.touches[0]; + const diffX = touch.clientX - startX; + const diffY = Math.abs(touch.clientY - startY); + + // Horizontal swipe detection + if (diffX > 50 && diffY < 50) { + this.updateSwipeIndicator(diffX); + } + }, { passive: true }); + + document.addEventListener('touchend', (e) => { + if (startX > 30) return; + + const endTime = Date.now(); + const timeDiff = endTime - startTime; + const touch = e.changedTouches[0]; + const diffX = touch.clientX - startX; + + this.hideSwipeIndicator(); + + // Quick swipe from edge + if (timeDiff < 300 && diffX > 100) { + this.navigateBack(); + } + }); + } + + // Long press for context actions + initLongPress() { + const longPressElements = document.querySelectorAll('[data-long-press]'); + + longPressElements.forEach(element => { + element.addEventListener('touchstart', (e) => { + this.longPressTimer = setTimeout(() => { + this.showContextMenu(element, e); + + // Haptic feedback + if (navigator.vibrate) { + navigator.vibrate(50); + } + }, 500); + }, { passive: true }); + + element.addEventListener('touchend', () => { + clearTimeout(this.longPressTimer); + }); + + element.addEventListener('touchmove', () => { + clearTimeout(this.longPressTimer); + }); + }); + } + + // Swipe actions on list items + initSwipeActions() { + const swipeElements = document.querySelectorAll('[data-swipe-actions]'); + + swipeElements.forEach(element => { + let startX = 0; + let currentX = 0; + let startTime = 0; + + element.addEventListener('touchstart', (e) => { + startX = e.touches[0].clientX; + startTime = Date.now(); + element.style.transition = 'none'; + }, { passive: true }); + + element.addEventListener('touchmove', (e) => { + currentX = e.touches[0].clientX; + const diffX = currentX - startX; + + // Limit swipe distance + const maxSwipe = 100; + const swipeX = Math.max(-maxSwipe, Math.min(maxSwipe, diffX)); + + element.style.transform = `translateX(${swipeX}px)`; + + // Show action hints + if (swipeX < -50) { + element.classList.add('swipe-left'); + element.classList.remove('swipe-right'); + } else if (swipeX > 50) { + element.classList.add('swipe-right'); + element.classList.remove('swipe-left'); + } else { + element.classList.remove('swipe-left', 'swipe-right'); + } + }, { passive: true }); + + element.addEventListener('touchend', (e) => { + const endTime = Date.now(); + const timeDiff = endTime - startTime; + const diffX = currentX - startX; + + element.style.transition = 'transform 0.3s ease'; + element.style.transform = ''; + + // Quick swipe actions + if (timeDiff < 300) { + if (diffX < -80) { + this.triggerSwipeAction(element, 'left'); + } else if (diffX > 80) { + this.triggerSwipeAction(element, 'right'); + } + } + + element.classList.remove('swipe-left', 'swipe-right'); + }); + }); + } + + // Pinch to zoom for images + initPinchZoom() { + const zoomElements = document.querySelectorAll('[data-zoomable]'); + + zoomElements.forEach(element => { + let initialDistance = 0; + let currentScale = 1; + + element.addEventListener('touchstart', (e) => { + if (e.touches.length === 2) { + initialDistance = this.getDistance(e.touches[0], e.touches[1]); + } + }, { passive: true }); + + element.addEventListener('touchmove', (e) => { + if (e.touches.length === 2) { + e.preventDefault(); + const currentDistance = this.getDistance(e.touches[0], e.touches[1]); + currentScale = currentDistance / initialDistance; + currentScale = Math.max(0.5, Math.min(3, currentScale)); + + element.style.transform = `scale(${currentScale})`; + } + }, { passive: false }); + + element.addEventListener('touchend', () => { + if (currentScale < 0.8 || currentScale > 2.5) { + element.style.transition = 'transform 0.3s ease'; + element.style.transform = 'scale(1)'; + currentScale = 1; + } + }); + }); + } + + // Helper methods + getDistance(touch1, touch2) { + const dx = touch1.clientX - touch2.clientX; + const dy = touch1.clientY - touch2.clientY; + return Math.sqrt(dx * dx + dy * dy); + } + + showSwipeIndicator() { + if (!this.swipeIndicator) { + this.swipeIndicator = document.createElement('div'); + this.swipeIndicator.className = 'swipe-back-indicator'; + this.swipeIndicator.innerHTML = ''; + document.body.appendChild(this.swipeIndicator); + } + this.swipeIndicator.classList.add('visible'); + } + + updateSwipeIndicator(progress) { + if (this.swipeIndicator) { + const opacity = Math.min(1, progress / 100); + const scale = 0.8 + (0.2 * opacity); + this.swipeIndicator.style.opacity = opacity; + this.swipeIndicator.style.transform = `translateX(${progress * 0.3}px) scale(${scale})`; + } + } + + hideSwipeIndicator() { + if (this.swipeIndicator) { + this.swipeIndicator.classList.remove('visible'); + } + } + + navigateBack() { + // Check if there's a back button + const backBtn = document.querySelector('.btn-back, [href*="javascript:history.back"]'); + if (backBtn) { + backBtn.click(); + } else { + history.back(); + } + } + + showContextMenu(element, event) { + const actions = element.dataset.longPress.split(','); + + // Create context menu + const menu = document.createElement('div'); + menu.className = 'mobile-context-menu'; + menu.style.top = `${event.touches[0].clientY}px`; + menu.style.left = `${event.touches[0].clientX}px`; + + actions.forEach(action => { + const [label, handler] = action.split(':'); + const item = document.createElement('button'); + item.textContent = label; + item.onclick = () => { + if (window[handler]) { + window[handler](element); + } + menu.remove(); + }; + menu.appendChild(item); + }); + + document.body.appendChild(menu); + + // Remove on outside click + setTimeout(() => { + document.addEventListener('touchstart', () => menu.remove(), { once: true }); + }, 100); + } + + triggerSwipeAction(element, direction) { + const action = element.dataset[`swipe${direction.charAt(0).toUpperCase() + direction.slice(1)}`]; + if (action && window[action]) { + window[action](element); + } + } + } + + // Add CSS for gestures + function addGestureStyles() { + if (document.getElementById('gesture-styles')) return; + + const styles = ` + + `; + + document.head.insertAdjacentHTML('beforeend', styles); + } + + // Initialize + document.addEventListener('DOMContentLoaded', function() { + addGestureStyles(); + new MobileGestures(); + }); + + window.MobileGestures = MobileGestures; +})(); \ No newline at end of file diff --git a/static/js/mobile-performance.js b/static/js/mobile-performance.js new file mode 100644 index 0000000..8c0e376 --- /dev/null +++ b/static/js/mobile-performance.js @@ -0,0 +1,323 @@ +// Mobile Performance Optimizations +(function() { + 'use strict'; + + // Lazy Loading for Images + class LazyLoader { + constructor() { + this.imageObserver = null; + this.init(); + } + + init() { + // Check for IntersectionObserver support + if ('IntersectionObserver' in window) { + this.imageObserver = new IntersectionObserver((entries, observer) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + this.loadImage(entry.target); + observer.unobserve(entry.target); + } + }); + }, { + rootMargin: '50px 0px', + threshold: 0.01 + }); + + // Start observing images + this.observeImages(); + } else { + // Fallback for older browsers + this.loadAllImages(); + } + } + + observeImages() { + const images = document.querySelectorAll('img[data-src]'); + images.forEach(img => this.imageObserver.observe(img)); + } + + loadImage(img) { + const src = img.dataset.src; + if (!src) return; + + // Create new image to preload + const newImg = new Image(); + newImg.onload = () => { + img.src = src; + img.classList.add('loaded'); + delete img.dataset.src; + }; + newImg.src = src; + } + + loadAllImages() { + const images = document.querySelectorAll('img[data-src]'); + images.forEach(img => this.loadImage(img)); + } + } + + // Virtual Scrolling for Long Lists + class VirtualScroller { + constructor(container, options = {}) { + this.container = container; + this.itemHeight = options.itemHeight || 80; + this.bufferSize = options.bufferSize || 5; + this.items = []; + this.visibleItems = []; + + if (this.container) { + this.init(); + } + } + + init() { + // Set up container + this.container.style.position = 'relative'; + this.container.style.overflow = 'auto'; + + // Create spacer for scrollbar + this.spacer = document.createElement('div'); + this.spacer.style.position = 'absolute'; + this.spacer.style.top = '0'; + this.spacer.style.left = '0'; + this.spacer.style.width = '1px'; + this.container.appendChild(this.spacer); + + // Set up scroll listener + this.container.addEventListener('scroll', this.onScroll.bind(this)); + + // Initial render + this.render(); + } + + setItems(items) { + this.items = items; + this.spacer.style.height = `${items.length * this.itemHeight}px`; + this.render(); + } + + onScroll() { + cancelAnimationFrame(this.scrollFrame); + this.scrollFrame = requestAnimationFrame(() => this.render()); + } + + render() { + const scrollTop = this.container.scrollTop; + const containerHeight = this.container.clientHeight; + + // Calculate visible range + const startIndex = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.bufferSize); + const endIndex = Math.min( + this.items.length - 1, + Math.ceil((scrollTop + containerHeight) / this.itemHeight) + this.bufferSize + ); + + // Update visible items + this.updateVisibleItems(startIndex, endIndex); + } + + updateVisibleItems(startIndex, endIndex) { + // Implementation depends on specific use case + // This is a simplified example + console.log(`Rendering items ${startIndex} to ${endIndex}`); + } + } + + // Request Idle Callback Polyfill + window.requestIdleCallback = window.requestIdleCallback || function(cb) { + const start = Date.now(); + return setTimeout(function() { + cb({ + didTimeout: false, + timeRemaining: function() { + return Math.max(0, 50 - (Date.now() - start)); + } + }); + }, 1); + }; + + // Debounce function for performance + function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + // Optimize form inputs + function optimizeInputs() { + // Debounce search inputs + const searchInputs = document.querySelectorAll('input[type="search"], .search-input'); + searchInputs.forEach(input => { + const originalHandler = input.oninput; + if (originalHandler) { + input.oninput = debounce(originalHandler, 300); + } + }); + + // Lazy load select options for large dropdowns + const largeSelects = document.querySelectorAll('select[data-lazy]'); + largeSelects.forEach(select => { + select.addEventListener('focus', function loadOptions() { + // Load options on first focus + if (this.dataset.loaded) return; + + const endpoint = this.dataset.lazy; + fetch(endpoint) + .then(response => response.json()) + .then(options => { + options.forEach(opt => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + this.appendChild(option); + }); + this.dataset.loaded = 'true'; + }); + }, { once: true }); + }); + } + + // Reduce motion for users who prefer it + function respectReducedMotion() { + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + + if (prefersReducedMotion.matches) { + document.documentElement.classList.add('reduce-motion'); + } + + prefersReducedMotion.addEventListener('change', (e) => { + if (e.matches) { + document.documentElement.classList.add('reduce-motion'); + } else { + document.documentElement.classList.remove('reduce-motion'); + } + }); + } + + // Optimize animations + function optimizeAnimations() { + // Use CSS containment + const cards = document.querySelectorAll('.card, .entry-card'); + cards.forEach(card => { + card.style.contain = 'layout style paint'; + }); + + // Use will-change sparingly + document.addEventListener('touchstart', (e) => { + const target = e.target.closest('.btn, .card, [data-animate]'); + if (target) { + target.style.willChange = 'transform'; + + // Remove after animation + setTimeout(() => { + target.style.willChange = 'auto'; + }, 300); + } + }); + } + + // Memory management + function setupMemoryManagement() { + // Clean up event listeners on page hide + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + // Pause non-critical operations + if (window.pauseBackgroundOperations) { + window.pauseBackgroundOperations(); + } + } else { + // Resume operations + if (window.resumeBackgroundOperations) { + window.resumeBackgroundOperations(); + } + } + }); + + // Clean up on navigation + window.addEventListener('pagehide', () => { + // Cancel pending requests + if (window.pendingRequests) { + window.pendingRequests.forEach(request => request.abort()); + } + }); + } + + // Battery-aware features + function setupBatteryAwareness() { + if ('getBattery' in navigator) { + navigator.getBattery().then(battery => { + function updateBatteryStatus() { + if (battery.level < 0.2 && !battery.charging) { + document.documentElement.classList.add('low-battery'); + // Reduce animations and background operations + } else { + document.documentElement.classList.remove('low-battery'); + } + } + + battery.addEventListener('levelchange', updateBatteryStatus); + battery.addEventListener('chargingchange', updateBatteryStatus); + updateBatteryStatus(); + }); + } + } + + // Network-aware loading + function setupNetworkAwareness() { + if ('connection' in navigator) { + const connection = navigator.connection; + + function updateNetworkStatus() { + const effectiveType = connection.effectiveType; + document.documentElement.dataset.networkSpeed = effectiveType; + + // Adjust quality based on connection + if (effectiveType === 'slow-2g' || effectiveType === '2g') { + document.documentElement.classList.add('low-quality'); + } else { + document.documentElement.classList.remove('low-quality'); + } + } + + connection.addEventListener('change', updateNetworkStatus); + updateNetworkStatus(); + } + } + + // Initialize all optimizations + function init() { + // Only run on mobile + if (window.innerWidth > 768) return; + + requestIdleCallback(() => { + new LazyLoader(); + optimizeInputs(); + respectReducedMotion(); + optimizeAnimations(); + setupMemoryManagement(); + setupBatteryAwareness(); + setupNetworkAwareness(); + }); + } + + // Start when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + // Export for use in other modules + window.MobilePerformance = { + LazyLoader, + VirtualScroller, + debounce + }; +})(); \ No newline at end of file diff --git a/static/js/mobile-pull-refresh.js b/static/js/mobile-pull-refresh.js new file mode 100644 index 0000000..455efa9 --- /dev/null +++ b/static/js/mobile-pull-refresh.js @@ -0,0 +1,260 @@ +// Pull-to-Refresh implementation for mobile +(function() { + 'use strict'; + + class PullToRefresh { + constructor(options = {}) { + this.container = options.container || document.querySelector('.content'); + this.onRefresh = options.onRefresh || (() => location.reload()); + this.threshold = options.threshold || 80; + this.max = options.max || 120; + + this.startY = 0; + this.currentY = 0; + this.pulling = false; + this.refreshing = false; + + if (this.container && this.isMobile()) { + this.init(); + } + } + + isMobile() { + return window.innerWidth <= 768 || + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); + } + + init() { + // Create pull-to-refresh indicator + this.createIndicator(); + + // Add touch event listeners + this.container.addEventListener('touchstart', this.onTouchStart.bind(this), { passive: true }); + this.container.addEventListener('touchmove', this.onTouchMove.bind(this), { passive: false }); + this.container.addEventListener('touchend', this.onTouchEnd.bind(this)); + } + + createIndicator() { + this.indicator = document.createElement('div'); + this.indicator.className = 'pull-refresh-indicator'; + this.indicator.innerHTML = ` +
+ +
+
Pull to refresh
+ `; + + // Insert at the beginning of container + this.container.insertBefore(this.indicator, this.container.firstChild); + + // Add styles + this.addStyles(); + } + + addStyles() { + if (document.getElementById('pull-refresh-styles')) return; + + const styles = ` + + `; + + document.head.insertAdjacentHTML('beforeend', styles); + } + + onTouchStart(e) { + if (this.refreshing) return; + + // Only activate at the top of the page + if (this.container.scrollTop === 0) { + this.startY = e.touches[0].clientY; + this.pulling = true; + } + } + + onTouchMove(e) { + if (!this.pulling || this.refreshing) return; + + this.currentY = e.touches[0].clientY; + const diff = this.currentY - this.startY; + + if (diff > 0) { + e.preventDefault(); + + // Calculate pull distance with resistance + const pullDistance = Math.min(diff * 0.5, this.max); + + // Update container transform + this.container.style.transform = `translateY(${pullDistance}px)`; + + // Update indicator + this.indicator.classList.add('visible'); + + if (pullDistance >= this.threshold) { + this.indicator.classList.add('pulling'); + this.updateText('Release to refresh'); + } else { + this.indicator.classList.remove('pulling'); + this.updateText('Pull to refresh'); + } + } + } + + onTouchEnd() { + if (!this.pulling || this.refreshing) return; + + const diff = this.currentY - this.startY; + const pullDistance = Math.min(diff * 0.5, this.max); + + this.pulling = false; + this.container.style.transition = 'transform 0.3s ease'; + + if (pullDistance >= this.threshold) { + // Trigger refresh + this.refresh(); + } else { + // Reset + this.reset(); + } + } + + refresh() { + this.refreshing = true; + this.container.style.transform = 'translateY(60px)'; + this.indicator.classList.add('refreshing'); + this.updateText('Refreshing...'); + + // Add haptic feedback if available + if (navigator.vibrate) { + navigator.vibrate(10); + } + + // Call refresh callback + Promise.resolve(this.onRefresh()).then(() => { + setTimeout(() => { + this.reset(); + }, 500); + }); + } + + reset() { + this.container.style.transform = ''; + this.indicator.classList.remove('visible', 'pulling', 'refreshing'); + + setTimeout(() => { + this.container.style.transition = ''; + this.refreshing = false; + this.currentY = 0; + }, 300); + } + + updateText(text) { + const textEl = this.indicator.querySelector('.pull-refresh-text'); + if (textEl) textEl.textContent = text; + } + } + + // Auto-initialize on common containers + document.addEventListener('DOMContentLoaded', function() { + // Time tracking page + if (document.querySelector('.time-tracking-container')) { + new PullToRefresh({ + container: document.querySelector('.time-tracking-container'), + onRefresh: () => { + // Refresh time entries + if (window.loadTimeEntries) { + return window.loadTimeEntries(); + } + } + }); + } + + // Notes list + if (document.querySelector('.notes-container')) { + new PullToRefresh({ + container: document.querySelector('.notes-container'), + onRefresh: () => { + // Refresh notes + if (window.refreshNotes) { + return window.refreshNotes(); + } + } + }); + } + }); + + // Expose globally + window.PullToRefresh = PullToRefresh; +})(); \ No newline at end of file diff --git a/static/js/mobile-tables.js b/static/js/mobile-tables.js new file mode 100644 index 0000000..500db64 --- /dev/null +++ b/static/js/mobile-tables.js @@ -0,0 +1,355 @@ +// Mobile Table Enhancements for TimeTrack +document.addEventListener('DOMContentLoaded', function() { + + // Configuration + const MOBILE_BREAKPOINT = 768; + const CARD_VIEW_BREAKPOINT = 576; + + // Initialize all data tables + function initMobileTables() { + const tables = document.querySelectorAll('.data-table, table'); + + tables.forEach(table => { + // Wrap tables in responsive container + if (!table.closest('.table-responsive')) { + const wrapper = document.createElement('div'); + wrapper.className = 'table-responsive'; + table.parentNode.insertBefore(wrapper, table); + wrapper.appendChild(table); + + // Check if table is scrollable + checkTableScroll(wrapper); + } + + // Add mobile-specific attributes + if (window.innerWidth <= CARD_VIEW_BREAKPOINT) { + convertTableToCards(table); + } + }); + } + + // Check if table needs horizontal scroll + function checkTableScroll(wrapper) { + const table = wrapper.querySelector('table'); + if (table.scrollWidth > wrapper.clientWidth) { + wrapper.classList.add('scrollable'); + addScrollIndicator(wrapper); + } else { + wrapper.classList.remove('scrollable'); + } + } + + // Add visual scroll indicator + function addScrollIndicator(wrapper) { + if (!wrapper.querySelector('.scroll-indicator')) { + const indicator = document.createElement('div'); + indicator.className = 'scroll-indicator'; + indicator.innerHTML = ' Scroll for more'; + wrapper.appendChild(indicator); + + // Hide indicator when scrolled to end + wrapper.addEventListener('scroll', function() { + const maxScroll = this.scrollWidth - this.clientWidth; + if (this.scrollLeft >= maxScroll - 10) { + indicator.style.opacity = '0'; + } else { + indicator.style.opacity = '1'; + } + }); + } + } + + // Convert table to card layout for mobile + function convertTableToCards(table) { + // Skip if already converted or marked to skip + if (table.classList.contains('no-card-view') || table.dataset.mobileCards === 'true') { + return; + } + + const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim()); + const rows = table.querySelectorAll('tbody tr'); + + // Create card container + const cardContainer = document.createElement('div'); + cardContainer.className = 'mobile-card-view'; + cardContainer.setAttribute('role', 'list'); + + rows.forEach((row, rowIndex) => { + const card = createCardFromRow(row, headers); + cardContainer.appendChild(card); + }); + + // Insert card view before table + table.parentNode.insertBefore(cardContainer, table); + + // Add classes for toggling + table.classList.add('desktop-table-view'); + table.dataset.mobileCards = 'true'; + } + + // Create a card element from table row + function createCardFromRow(row, headers) { + const cells = row.querySelectorAll('td'); + const card = document.createElement('div'); + card.className = 'table-card'; + card.setAttribute('role', 'listitem'); + + // Check for special data attributes + const primaryField = row.dataset.primaryField || 0; + const secondaryField = row.dataset.secondaryField || 1; + + // Create card header with primary info + if (cells[primaryField]) { + const cardHeader = document.createElement('div'); + cardHeader.className = 'table-card-header'; + cardHeader.innerHTML = cells[primaryField].innerHTML; + card.appendChild(cardHeader); + } + + // Create card body with other fields + const cardBody = document.createElement('div'); + cardBody.className = 'table-card-body'; + + cells.forEach((cell, index) => { + // Skip primary field as it's in header + if (index === primaryField) return; + + const field = document.createElement('div'); + field.className = 'table-card-field'; + + const label = document.createElement('span'); + label.className = 'table-card-label'; + label.textContent = headers[index] || ''; + + const value = document.createElement('span'); + value.className = 'table-card-value'; + value.innerHTML = cell.innerHTML; + + field.appendChild(label); + field.appendChild(value); + cardBody.appendChild(field); + }); + + card.appendChild(cardBody); + + // Copy any data attributes from row + Array.from(row.attributes).forEach(attr => { + if (attr.name.startsWith('data-')) { + card.setAttribute(attr.name, attr.value); + } + }); + + // Copy click handlers if any + if (row.onclick) { + card.onclick = row.onclick; + card.style.cursor = 'pointer'; + } + + return card; + } + + // Time entry table specific enhancements + function enhanceTimeEntryTable() { + const timeTable = document.querySelector('.time-entries-table'); + if (!timeTable) return; + + if (window.innerWidth <= MOBILE_BREAKPOINT) { + // Add swipe actions for time entries + addSwipeActions(timeTable); + + // Compact view for mobile + timeTable.classList.add('mobile-compact'); + } + } + + // Add swipe gestures to table rows + function addSwipeActions(table) { + const rows = table.querySelectorAll('tbody tr'); + + rows.forEach(row => { + let startX = 0; + let currentX = 0; + let isDragging = false; + + row.addEventListener('touchstart', handleTouchStart, { passive: true }); + row.addEventListener('touchmove', handleTouchMove, { passive: true }); + row.addEventListener('touchend', handleTouchEnd); + + function handleTouchStart(e) { + startX = e.touches[0].clientX; + isDragging = true; + row.style.transition = 'none'; + } + + function handleTouchMove(e) { + if (!isDragging) return; + + currentX = e.touches[0].clientX; + const diffX = currentX - startX; + + // Limit swipe distance + const maxSwipe = 100; + const swipeX = Math.max(-maxSwipe, Math.min(maxSwipe, diffX)); + + row.style.transform = `translateX(${swipeX}px)`; + + // Show action indicators + if (swipeX < -50) { + row.classList.add('swipe-delete'); + } else if (swipeX > 50) { + row.classList.add('swipe-edit'); + } else { + row.classList.remove('swipe-delete', 'swipe-edit'); + } + } + + function handleTouchEnd(e) { + if (!isDragging) return; + + const diffX = currentX - startX; + row.style.transition = 'transform 0.3s ease'; + row.style.transform = ''; + + // Trigger actions based on swipe distance + if (diffX < -80) { + // Delete action + const deleteBtn = row.querySelector('.delete-btn'); + if (deleteBtn) deleteBtn.click(); + } else if (diffX > 80) { + // Edit action + const editBtn = row.querySelector('.edit-btn'); + if (editBtn) editBtn.click(); + } + + row.classList.remove('swipe-delete', 'swipe-edit'); + isDragging = false; + } + }); + } + + // Handle responsive table on window resize + function handleResize() { + const tables = document.querySelectorAll('.table-responsive'); + tables.forEach(wrapper => { + checkTableScroll(wrapper); + }); + + // Re-initialize tables if crossing breakpoint + if (window.innerWidth <= CARD_VIEW_BREAKPOINT) { + initMobileTables(); + } + + // Update time entry table + enhanceTimeEntryTable(); + } + + // Add CSS for card view + function injectCardStyles() { + if (document.getElementById('mobile-table-styles')) return; + + const styles = ` + + `; + + document.head.insertAdjacentHTML('beforeend', styles); + } + + // Initialize on load + injectCardStyles(); + initMobileTables(); + enhanceTimeEntryTable(); + + // Handle window resize + let resizeTimer; + window.addEventListener('resize', function() { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(handleResize, 250); + }); + + // Export functions for external use + window.MobileTables = { + init: initMobileTables, + convertToCards: convertTableToCards, + enhanceTimeEntry: enhanceTimeEntryTable + }; +}); \ No newline at end of file diff --git a/static/js/sidebar.js b/static/js/sidebar.js index dbf66f7..8bdb417 100644 --- a/static/js/sidebar.js +++ b/static/js/sidebar.js @@ -3,7 +3,7 @@ document.addEventListener('DOMContentLoaded', function() { const sidebar = document.getElementById('sidebar'); const sidebarToggle = document.getElementById('sidebar-toggle'); const mobileNavToggle = document.getElementById('mobile-nav-toggle'); - const mobileOverlay = document.getElementById('mobile-overlay'); + const mobileOverlay = document.getElementById('mobile-nav-overlay'); // Desktop sidebar toggle if (sidebarToggle) { @@ -27,18 +27,20 @@ document.addEventListener('DOMContentLoaded', function() { // Mobile navigation toggle if (mobileNavToggle) { mobileNavToggle.addEventListener('click', function() { - sidebar.classList.toggle('mobile-open'); + sidebar.classList.toggle('active'); mobileOverlay.classList.toggle('active'); mobileNavToggle.classList.toggle('active'); + document.body.classList.toggle('mobile-nav-open'); }); } // Close mobile sidebar when clicking overlay if (mobileOverlay) { mobileOverlay.addEventListener('click', function() { - sidebar.classList.remove('mobile-open'); + sidebar.classList.remove('active'); mobileOverlay.classList.remove('active'); if (mobileNavToggle) mobileNavToggle.classList.remove('active'); + document.body.classList.remove('mobile-nav-open'); }); } diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..69a5a9a --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,101 @@ +{ + "name": "TimeTrack - Time Management", + "short_name": "TimeTrack", + "description": "Professional time tracking and project management application", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#667eea", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/static/icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/static/icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/static/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/static/icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/static/icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/static/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/static/icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/static/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable any" + } + ], + "shortcuts": [ + { + "name": "Start Timer", + "short_name": "Timer", + "description": "Start tracking time", + "url": "/time-tracking", + "icons": [ + { + "src": "/static/icons/timer-96x96.png", + "sizes": "96x96" + } + ] + }, + { + "name": "View Projects", + "short_name": "Projects", + "description": "View all projects", + "url": "/projects", + "icons": [ + { + "src": "/static/icons/projects-96x96.png", + "sizes": "96x96" + } + ] + }, + { + "name": "Create Note", + "short_name": "Note", + "description": "Create a new note", + "url": "/notes/create", + "icons": [ + { + "src": "/static/icons/note-96x96.png", + "sizes": "96x96" + } + ] + } + ], + "categories": ["productivity", "business"], + "prefer_related_applications": false, + "related_applications": [] +} \ No newline at end of file diff --git a/templates/_time_tracking_interface.html b/templates/_time_tracking_interface.html index b6f6eec..eb9a6e2 100644 --- a/templates/_time_tracking_interface.html +++ b/templates/_time_tracking_interface.html @@ -220,7 +220,7 @@
- {{ entry.arrival_time.strftime('%d') }} + {{ entry.arrival_time.day }} {{ entry.arrival_time.strftime('%b') }}
@@ -304,7 +304,7 @@
{{ entry.duration|format_duration if entry.duration is not none else 'Active' }} diff --git a/templates/admin_company.html b/templates/admin_company.html index d0569c8..3d80f35 100644 --- a/templates/admin_company.html +++ b/templates/admin_company.html @@ -114,7 +114,7 @@
- {{ company.created_at.strftime('%B %d, %Y at %I:%M %p') }} + {{ company.created_at|format_datetime }}
diff --git a/templates/admin_projects.html b/templates/admin_projects.html index ddfe06f..cd3bbd9 100644 --- a/templates/admin_projects.html +++ b/templates/admin_projects.html @@ -240,8 +240,8 @@ {% if project.start_date %} - {{ project.start_date.strftime('%Y-%m-%d') }} - {% if project.end_date %}
to {{ project.end_date.strftime('%Y-%m-%d') }}{% endif %} + {{ project.start_date|format_date }} + {% if project.end_date %}
to {{ project.end_date|format_date }}{% endif %} {% else %} - {% endif %} diff --git a/templates/config.html b/templates/config.html index 09bbe8b..359a78e 100644 --- a/templates/config.html +++ b/templates/config.html @@ -12,7 +12,7 @@

These policies are set by your administrator and apply to all employees. {% if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN %} - Click here to modify these settings. + Click here to modify these settings. {% endif %}

@@ -25,20 +25,12 @@
Break Policy: - {% if company_config.mandatory_break_minutes > 0 %} + {% if company_config.require_breaks %} {{ company_config.break_duration_minutes }} minutes after {{ company_config.break_after_hours }} hours {% else %} No mandatory breaks {% endif %}
-
- Additional Break: - {% if company_config.additional_break_minutes > 0 %} - {{ company_config.additional_break_minutes }} minutes after {{ company_config.additional_break_threshold_hours }} hours - {% else %} - No additional breaks - {% endif %} -
{% endif %} diff --git a/templates/index.html b/templates/index.html index d216328..21872f8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,5 +1,64 @@ {% extends "layout.html" %} +{% block meta_description %}{{ g.branding.app_name if g.branding else 'TimeTrack' }} - Free enterprise time tracking software with project management, team collaboration, billing & invoicing. Track time, manage projects, generate reports. Open-source & self-hosted.{% endblock %} + +{% block meta_keywords %}time tracking software, project time tracker, team time management, billing and invoicing, enterprise time tracking, open source time tracker, self-hosted time tracking, project management software, team productivity tools, work hours tracker{% endblock %} + +{% block og_title %}{{ g.branding.app_name if g.branding else 'TimeTrack' }} - Enterprise Time Tracking & Project Management{% endblock %} + +{% block og_description %}Transform your team's productivity with intelligent time tracking, real-time analytics, project management, and automated billing. Free, open-source, and enterprise-ready.{% endblock %} + +{% block structured_data %} + + + +{% endblock %} + {% block content %} {% if not g.user %} diff --git a/templates/layout.html b/templates/layout.html index 3846385..4ac23c3 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -3,11 +3,47 @@ - {{ title }} - {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% if g.company %} - {{ g.company.name }}{% endif %} + {% if title == 'Home' %}{{ g.branding.app_name if g.branding else 'TimeTrack' }} - Enterprise Time Tracking & Project Management Software{% else %}{{ title }} - {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% endif %}{% if g.company %} - {{ g.company.name }}{% endif %} + + + + + + + + + + + + + + + {% if g.branding and g.branding.logo_filename %} + + {% endif %} + + + + + + {% if g.branding and g.branding.logo_filename %} + + {% endif %} + + + + + + + + + + + {% if g.user %} {% endif %} @@ -46,9 +82,25 @@ color: inherit; text-decoration: none; } + /* Fix mobile hamburger menu visibility */ + @media (max-width: 768px) { + #mobile-nav-toggle span { + background-color: var(--primary-color, #667eea) !important; + display: block !important; + opacity: 1 !important; + visibility: visible !important; + } + } - + + {% if g.user and g.user.preferences %} + + + {% endif %} {% if g.user %}
@@ -69,6 +121,7 @@
+
{% endif %} @@ -245,10 +298,44 @@ + + + {% if g.user %} + + {% endif %} + + + + + + {% if g.user %}