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 @@
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 %}
+
{% endif %}
@@ -245,10 +298,44 @@
+
+
+ {% if g.user %}
+
+ {% endif %}
+
+
+
+
+
+
{% if g.user %}
|