Merge pull request #9 from DRYTRIX/feature-MobileFriendly

feat: implement comprehensive mobile-friendly web interface
This commit is contained in:
Dries Peeters
2025-08-29 09:32:02 +02:00
committed by GitHub
8 changed files with 2165 additions and 258 deletions

249
MOBILE_IMPROVEMENTS.md Normal file
View File

@@ -0,0 +1,249 @@
# Mobile-Friendly Improvements for TimeTracker
This document outlines all the mobile-friendly improvements implemented in the TimeTracker application to provide an optimal experience on mobile devices.
## 🎯 Overview
The TimeTracker application has been completely redesigned with a mobile-first approach, ensuring excellent usability across all device sizes, from small mobile phones to large desktop screens.
## 📱 Key Mobile Improvements
### 1. **Enhanced Mobile Navigation**
- **Collapsible Mobile Menu**: Responsive navigation that collapses into a hamburger menu on mobile
- **Touch-Friendly Navigation**: Larger touch targets (44px minimum) for all navigation elements
- **Swipe to Close**: Users can swipe left/right to close the mobile navigation menu
- **Auto-Close**: Navigation automatically closes when clicking outside or selecting a menu item
### 2. **Mobile-Optimized Layouts**
- **Responsive Grid System**: Bootstrap-based responsive grid that adapts to screen size
- **Mobile-First Design**: Design starts with mobile and scales up to desktop
- **Flexible Containers**: Containers and cards that stack properly on small screens
- **Optimized Spacing**: Mobile-specific margins and padding for better visual hierarchy
### 3. **Touch-Friendly Interface Elements**
- **Large Touch Targets**: All buttons, links, and form inputs meet the 44px minimum touch target requirement
- **Touch Feedback**: Visual feedback when touching elements (scale animations)
- **Improved Button Sizes**: Larger buttons on mobile for easier interaction
- **Better Form Controls**: Larger form inputs that prevent accidental zoom on iOS
### 4. **Mobile-Responsive Tables**
- **Card-Based Layout**: Tables transform into card layouts on mobile devices
- **Data Labels**: Each table cell shows its column header on mobile
- **Stacked Information**: Table data is presented in a vertical, easy-to-read format
- **Touch-Friendly Actions**: Action buttons are properly sized and spaced for mobile
### 5. **Enhanced Mobile Forms**
- **Mobile-Optimized Inputs**: Form inputs sized appropriately for mobile devices
- **Better Validation**: Mobile-friendly error messages and validation feedback
- **Improved Layout**: Form fields stack vertically on mobile for better usability
- **Touch-Friendly Controls**: All form elements meet touch target requirements
### 6. **Mobile-Specific Features**
- **Pull-to-Refresh**: Swipe down to refresh page content
- **Swipe Navigation**: Swipe left/right for browser navigation
- **Touch Gestures**: Intuitive touch interactions throughout the interface
- **Mobile Keyboard Handling**: Automatic scrolling to focused form inputs
### 7. **Performance Optimizations**
- **Lazy Loading**: Images and content load as needed
- **Optimized Animations**: Reduced motion for users who prefer it
- **Efficient Scrolling**: Smooth, optimized scrolling performance
- **Mobile-Specific CSS**: Dedicated mobile stylesheets for better performance
## 🛠️ Technical Implementation
### CSS Improvements
- **Mobile-First Media Queries**: CSS written for mobile first, then enhanced for larger screens
- **CSS Custom Properties**: Variables for consistent mobile sizing and spacing
- **Flexbox Layouts**: Modern CSS layouts that work well on all devices
- **Mobile-Specific Classes**: Utility classes for mobile-specific styling
### JavaScript Enhancements
- **Mobile Detection**: Automatic detection of mobile devices
- **Touch Event Handling**: Proper touch event management
- **Responsive Behavior**: JavaScript that adapts to screen size changes
- **Performance Monitoring**: Mobile-specific performance optimizations
### HTML Structure
- **Semantic Markup**: Proper HTML5 semantic elements
- **Accessibility**: ARIA labels and proper accessibility attributes
- **Mobile Meta Tags**: Proper viewport and mobile web app meta tags
- **Responsive Images**: Images that scale appropriately for different screen sizes
## 📱 Device Support
### Mobile Phones
- **Small Phones** (≤480px): Optimized layouts with full-width elements
- **Standard Phones** (≤768px): Responsive layouts with mobile-specific features
- **Large Phones** (≤991px): Enhanced mobile experience with touch gestures
### Tablets
- **Small Tablets** (≤1024px): Tablet-optimized layouts
- **Large Tablets** (≤1200px): Enhanced tablet experience
### Desktop
- **Desktop** (>1200px): Full-featured desktop experience
## 🎨 Design Principles
### 1. **Accessibility First**
- High contrast ratios for better readability
- Proper focus states for keyboard navigation
- Screen reader friendly markup
- Touch target size compliance
### 2. **Performance Focused**
- Minimal JavaScript execution on mobile
- Optimized CSS delivery
- Efficient DOM manipulation
- Reduced network requests
### 3. **User Experience**
- Intuitive touch interactions
- Consistent visual feedback
- Smooth animations and transitions
- Clear visual hierarchy
## 🚀 Features by Page
### Dashboard
- **Mobile-Optimized Cards**: Statistics and quick action cards stack properly
- **Touch-Friendly Timer**: Large, easy-to-use timer controls
- **Responsive Tables**: Recent entries table transforms for mobile
- **Mobile Navigation**: Easy access to all dashboard features
### Projects
- **Mobile Table Layout**: Project lists display as cards on mobile
- **Touch-Friendly Actions**: Edit, view, and delete buttons properly sized
- **Responsive Filters**: Filter controls stack vertically on mobile
- **Mobile Forms**: Create and edit project forms optimized for mobile
### Timer/Log Time
- **Mobile Form Layout**: Form fields stack vertically for mobile
- **Touch-Friendly Inputs**: Date and time pickers optimized for mobile
- **Mobile Validation**: Mobile-friendly error messages
- **Responsive Buttons**: Full-width buttons on mobile
### Reports
- **Mobile Charts**: Charts that scale appropriately for mobile
- **Touch-Friendly Navigation**: Easy navigation between report types
- **Mobile Data Display**: Data presented in mobile-friendly formats
- **Responsive Filters**: Date and project filters optimized for mobile
## 🔧 Customization
### Adding Mobile-Specific Styles
```css
/* Use mobile-specific utility classes */
.mobile-stack { /* Stack elements vertically on mobile */ }
.mobile-btn { /* Full-width mobile buttons */ }
.mobile-card { /* Mobile-optimized cards */ }
.touch-target { /* Ensure proper touch target size */ }
```
### Mobile JavaScript Features
```javascript
// Access mobile enhancer instance
const mobileEnhancer = new MobileEnhancer();
// Check if device is mobile
if (mobileEnhancer.isMobile) {
// Apply mobile-specific logic
}
```
### Responsive Breakpoints
```css
/* Mobile first approach */
@media (max-width: 768px) { /* Mobile styles */ }
@media (max-width: 480px) { /* Small mobile styles */ }
@media (min-width: 769px) { /* Desktop styles */ }
```
## 📊 Performance Metrics
### Mobile Performance Targets
- **First Contentful Paint**: < 1.5 seconds
- **Largest Contentful Paint**: < 2.5 seconds
- **Cumulative Layout Shift**: < 0.1
- **First Input Delay**: < 100ms
### Optimization Techniques
- **CSS Optimization**: Minified and optimized mobile CSS
- **JavaScript Optimization**: Efficient mobile JavaScript execution
- **Image Optimization**: Responsive images with appropriate sizes
- **Font Optimization**: Web fonts optimized for mobile
## 🧪 Testing
### Mobile Testing Checklist
- [ ] Test on various mobile devices and screen sizes
- [ ] Verify touch target sizes (44px minimum)
- [ ] Test mobile navigation functionality
- [ ] Verify responsive table layouts
- [ ] Test mobile form interactions
- [ ] Verify mobile-specific features
- [ ] Test performance on mobile networks
- [ ] Verify accessibility on mobile devices
### Testing Tools
- **Browser DevTools**: Mobile device simulation
- **Real Devices**: Physical mobile device testing
- **Performance Tools**: Lighthouse mobile audits
- **Accessibility Tools**: Mobile accessibility testing
## 🔮 Future Enhancements
### Planned Mobile Features
- **Offline Support**: PWA capabilities for offline time tracking
- **Mobile Notifications**: Push notifications for timer events
- **Gesture Controls**: Advanced touch gesture support
- **Mobile Analytics**: Mobile-specific usage analytics
- **Dark Mode**: Mobile-optimized dark theme
- **Haptic Feedback**: Touch feedback on supported devices
### Mobile App Considerations
- **Native App**: Potential React Native or Flutter mobile app
- **Hybrid App**: Cordova/PhoneGap wrapper for web app
- **PWA Features**: Progressive Web App enhancements
- **Mobile SDKs**: Integration with mobile development tools
## 📚 Resources
### Mobile Development Best Practices
- [Google Mobile Guidelines](https://developers.google.com/web/fundamentals/design-and-ux/principles)
- [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/)
- [Material Design Mobile](https://material.io/design/platform-guidance/platform-adaptation.html)
### Testing Resources
- [Mobile-Friendly Test](https://search.google.com/test/mobile-friendly)
- [Lighthouse Mobile](https://developers.google.com/web/tools/lighthouse)
- [WebPageTest Mobile](https://www.webpagetest.org/mobile)
### Performance Tools
- [PageSpeed Insights](https://pagespeed.web.dev/)
- [WebPageTest](https://www.webpagetest.org/)
- [GTmetrix](https://gtmetrix.com/)
## 🤝 Contributing
When contributing to mobile improvements:
1. **Test on Mobile**: Always test changes on mobile devices
2. **Follow Guidelines**: Use established mobile design patterns
3. **Performance First**: Ensure changes don't impact mobile performance
4. **Accessibility**: Maintain accessibility standards on mobile
5. **Documentation**: Update this document with new mobile features
## 📝 Changelog
### Version 1.0.0 - Initial Mobile Release
- Complete mobile-first redesign
- Touch-friendly interface elements
- Mobile-responsive layouts
- Mobile-specific JavaScript enhancements
- Comprehensive mobile CSS framework
---
*This document is maintained by the TimeTracker development team. For questions or suggestions about mobile improvements, please open an issue or contact the development team.*

558
app/static/mobile.css Normal file
View File

@@ -0,0 +1,558 @@
/* Mobile-First CSS for TimeTracker */
/* Mobile-specific variables */
:root {
--mobile-touch-target: 44px;
--mobile-nav-height: 60px;
--mobile-card-padding: 1rem;
--mobile-button-height: 48px;
--mobile-input-height: 48px;
}
/* Mobile-specific improvements */
@media (max-width: 768px) {
/* Container improvements */
.container, .container-fluid {
padding-left: 1rem;
padding-right: 1rem;
}
/* Row and column improvements */
.row {
margin-left: -0.5rem;
margin-right: -0.5rem;
}
.col, .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
/* Card improvements */
.card {
margin-bottom: 1rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.card-body {
padding: var(--mobile-card-padding);
}
.card-header {
padding: 1rem var(--mobile-card-padding);
}
/* Button improvements */
.btn {
min-height: var(--mobile-button-height);
padding: 0.875rem 1.5rem;
font-size: 1rem;
border-radius: 8px;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
transition: all 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.btn:active {
transform: scale(0.98);
}
.btn-sm {
min-height: 40px;
padding: 0.75rem 1.25rem;
font-size: 0.9rem;
}
.btn-lg {
min-height: 56px;
padding: 1.125rem 2rem;
font-size: 1.125rem;
}
/* Button group improvements */
.btn-group {
display: flex;
flex-direction: column;
width: 100%;
}
.btn-group .btn {
border-radius: 8px !important;
margin-bottom: 0.5rem;
width: 100%;
}
.btn-group .btn:last-child {
margin-bottom: 0;
}
/* Form improvements */
.form-control, .form-select {
min-height: var(--mobile-input-height);
padding: 0.875rem 1rem;
font-size: 16px; /* Prevents zoom on iOS */
border-radius: 8px;
border: 2px solid #e2e8f0;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
outline: none;
}
.form-label {
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
font-size: 0.95rem;
display: block;
}
.form-text {
font-size: 0.875rem;
color: #64748b;
margin-top: 0.25rem;
}
/* Table improvements for mobile */
.table-responsive {
border: none;
border-radius: 12px;
overflow: hidden;
}
.table {
display: block;
width: 100%;
margin-bottom: 0;
}
.table thead {
display: none;
}
.table tbody {
display: block;
width: 100%;
}
.table tr {
display: block;
margin-bottom: 1rem;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.table td {
display: block;
text-align: left;
padding: 1rem;
border: none;
border-bottom: 1px solid #f1f5f9;
position: relative;
}
.table td:last-child {
border-bottom: none;
}
.table td:before {
content: attr(data-label) ": ";
font-weight: 600;
color: #1e293b;
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #64748b;
}
.table td.actions-cell {
text-align: center;
padding: 1rem;
background: #f8fafc;
}
.table td.actions-cell:before {
display: none;
}
/* Navigation improvements */
.navbar {
min-height: var(--mobile-nav-height);
padding: 0.75rem 0;
}
.navbar-brand {
font-size: 1.2rem;
}
.navbar-toggler {
border: none;
padding: 0.5rem;
min-height: var(--mobile-touch-target);
min-width: var(--mobile-touch-target);
border-radius: 8px;
}
.navbar-toggler:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.navbar-collapse {
background: white;
border-top: 1px solid #e2e8f0;
margin-top: 0.5rem;
padding: 1rem 0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border-radius: 0 0 12px 12px;
}
.navbar-nav .nav-link {
padding: 1rem 1.5rem;
margin: 0.25rem 0;
border-radius: 8px;
font-size: 1.1rem;
min-height: var(--mobile-touch-target);
display: flex;
align-items: center;
}
.navbar-nav .nav-link i {
width: 24px;
text-align: center;
margin-right: 0.75rem;
}
.dropdown-menu {
position: static !important;
float: none;
width: 100%;
margin: 0;
border: none;
box-shadow: none;
background: #f8fafc;
border-radius: 8px;
margin-top: 0.5rem;
}
.dropdown-item {
padding: 0.75rem 2rem;
border-radius: 8px;
margin: 0.25rem 0.5rem;
min-height: var(--mobile-touch-target);
display: flex;
align-items: center;
}
/* Modal improvements */
.modal-dialog {
margin: 0.5rem;
max-width: calc(100% - 1rem);
}
.modal-content {
border-radius: 12px;
border: none;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.modal-header, .modal-body, .modal-footer {
padding: 1rem;
}
.modal-footer {
flex-direction: column;
}
.modal-footer .btn {
width: 100%;
margin-bottom: 0.5rem;
}
.modal-footer .btn:last-child {
margin-bottom: 0;
}
/* Typography improvements */
h1 { font-size: 1.75rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; }
h5 { font-size: 1rem; }
h6 { font-size: 0.95rem; }
/* Spacing improvements */
.mb-4 { margin-bottom: 1.5rem !important; }
.mb-3 { margin-bottom: 1rem !important; }
.mb-2 { margin-bottom: 0.75rem !important; }
.py-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; }
.py-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; }
/* Utility classes */
.mobile-stack {
display: flex;
flex-direction: column;
}
.mobile-stack .btn {
margin-bottom: 0.5rem;
width: 100%;
}
.mobile-stack .btn:last-child {
margin-bottom: 0;
}
.touch-target {
min-height: var(--mobile-touch-target);
min-width: var(--mobile-touch-target);
}
/* Mobile-specific components */
.mobile-card {
margin-bottom: 1rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.mobile-btn {
width: 100%;
margin-bottom: 0.75rem;
padding: 1rem 1.5rem;
font-size: 1rem;
min-height: var(--mobile-button-height);
}
.mobile-btn:last-child {
margin-bottom: 0;
}
/* Mobile table improvements */
.mobile-table-row {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
margin-bottom: 1rem;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.mobile-table-row .row {
margin: 0;
}
.mobile-table-row .col {
padding: 0.5rem 0;
border-bottom: 1px solid #f1f5f9;
}
.mobile-table-row .col:last-child {
border-bottom: none;
}
.mobile-table-row .col:before {
content: attr(data-label) ": ";
font-weight: 600;
color: #1e293b;
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #64748b;
}
/* Mobile form improvements */
.mobile-form-group {
margin-bottom: 1.5rem;
}
.mobile-form-group .form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
.mobile-form-group .form-control,
.mobile-form-group .form-select {
width: 100%;
}
/* Mobile navigation improvements */
.mobile-nav-item {
padding: 1rem 1.5rem;
border-radius: 8px;
margin: 0.25rem 0;
transition: all 0.2s ease;
min-height: var(--mobile-touch-target);
display: flex;
align-items: center;
}
.mobile-nav-item:hover {
background: #f8fafc;
}
.mobile-nav-item.active {
background: #3b82f6;
color: white;
}
/* Mobile-specific animations */
.mobile-fade-in {
animation: mobileFadeIn 0.3s ease-in;
}
@keyframes mobileFadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Mobile-specific shadows */
.mobile-shadow {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.mobile-shadow-hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
/* Mobile-specific borders */
.mobile-border {
border: 1px solid #e2e8f0;
border-radius: 12px;
}
/* Mobile-specific backgrounds */
.mobile-bg-light {
background-color: #f8fafc;
}
.mobile-bg-white {
background-color: white;
}
/* Mobile-specific text colors */
.mobile-text-primary {
color: #1e293b !important;
}
.mobile-text-secondary {
color: #475569 !important;
}
.mobile-text-muted {
color: #64748b !important;
}
/* Mobile-specific spacing utilities */
.mobile-p-0 { padding: 0 !important; }
.mobile-p-1 { padding: 0.25rem !important; }
.mobile-p-2 { padding: 0.5rem !important; }
.mobile-p-3 { padding: 1rem !important; }
.mobile-p-4 { padding: 1.5rem !important; }
.mobile-m-0 { margin: 0 !important; }
.mobile-m-1 { margin: 0.25rem !important; }
.mobile-m-2 { margin: 0.5rem !important; }
.mobile-m-3 { margin: 1rem !important; }
.mobile-m-4 { margin: 1.5rem !important; }
}
/* Small mobile devices */
@media (max-width: 480px) {
.container, .container-fluid {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.card-body {
padding: 0.875rem;
}
.btn {
padding: 1rem 1.25rem;
font-size: 0.95rem;
}
.form-control, .form-select {
padding: 0.75rem 0.875rem;
font-size: 16px;
}
.navbar-brand {
font-size: 1.1rem;
}
.table td {
padding: 0.875rem;
}
.modal-dialog {
margin: 0.25rem;
max-width: calc(100% - 0.5rem);
}
.modal-header, .modal-body, .modal-footer {
padding: 0.875rem;
}
}
/* Landscape mobile devices */
@media (max-width: 768px) and (orientation: landscape) {
.navbar-collapse {
max-height: 70vh;
overflow-y: auto;
}
.modal-dialog {
margin: 1rem;
max-width: calc(100% - 2rem);
}
}
/* High-DPI mobile devices */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.btn, .form-control, .form-select {
border-width: 0.5px;
}
.table td {
border-bottom-width: 0.5px;
}
}
/* Mobile-specific print styles */
@media print and (max-width: 768px) {
.navbar, .btn, .modal {
display: none !important;
}
.card {
border: 1px solid #000 !important;
box-shadow: none !important;
}
.table td {
border: 1px solid #000 !important;
}
}

531
app/static/mobile.js Normal file
View File

@@ -0,0 +1,531 @@
// Mobile-specific JavaScript for TimeTracker
class MobileEnhancer {
constructor() {
this.isMobile = window.innerWidth <= 768;
this.touchStartX = 0;
this.touchStartY = 0;
this.touchEndX = 0;
this.touchEndY = 0;
this.init();
}
init() {
this.detectMobile();
this.enhanceTouchTargets();
this.enhanceTables();
this.enhanceNavigation();
this.enhanceModals();
this.enhanceForms();
this.addTouchGestures();
this.handleViewportChanges();
this.addMobileSpecificFeatures();
}
detectMobile() {
if (this.isMobile) {
document.body.classList.add('mobile-view');
this.addMobileMetaTags();
}
}
addMobileMetaTags() {
// Add mobile-specific meta tags if not already present
if (!document.querySelector('meta[name="mobile-web-app-capable"]')) {
const meta = document.createElement('meta');
meta.name = 'mobile-web-app-capable';
meta.content = 'yes';
document.head.appendChild(meta);
}
if (!document.querySelector('meta[name="apple-mobile-web-app-capable"]')) {
const meta = document.createElement('meta');
meta.name = 'apple-mobile-web-app-capable';
meta.content = 'yes';
document.head.appendChild(meta);
}
}
enhanceTouchTargets() {
// Improve touch targets for mobile
const touchElements = document.querySelectorAll('.btn, .form-control, .form-select, .nav-link, .dropdown-item');
touchElements.forEach(element => {
element.classList.add('touch-target');
// Add touch feedback
element.addEventListener('touchstart', this.handleTouchStart.bind(this));
element.addEventListener('touchend', this.handleTouchEnd.bind(this));
});
}
// Add missing touch event handlers
handleTouchStart(event) {
const element = event.currentTarget;
element.style.transform = 'scale(0.98)';
element.style.transition = 'transform 0.1s ease';
// Store touch coordinates for gesture detection
this.touchStartX = event.touches[0].clientX;
this.touchStartY = event.touches[0].clientY;
}
handleTouchEnd(event) {
const element = event.currentTarget;
element.style.transform = 'scale(1)';
// Store end coordinates for gesture detection
this.touchEndX = event.changedTouches[0].clientX;
this.touchEndY = event.changedTouches[0].clientY;
}
enhanceTables() {
if (!this.isMobile) return;
const tables = document.querySelectorAll('.table');
tables.forEach(table => {
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
cells.forEach((cell, index) => {
// Add data-label attributes for mobile table display
const header = table.querySelector(`thead th:nth-child(${index + 1})`);
if (header) {
cell.setAttribute('data-label', header.textContent.trim());
}
});
});
});
}
enhanceNavigation() {
if (!this.isMobile) return;
const navbarToggler = document.querySelector('.navbar-toggler');
const navbarCollapse = document.querySelector('.navbar-collapse');
if (navbarToggler && navbarCollapse) {
// Close mobile menu when clicking outside
document.addEventListener('click', (event) => {
const isClickInsideNavbar = navbarToggler.contains(event.target) || navbarCollapse.contains(event.target);
if (!isClickInsideNavbar && navbarCollapse.classList.contains('show')) {
const bsCollapse = new bootstrap.Collapse(navbarCollapse);
bsCollapse.hide();
}
});
// Close mobile menu when clicking on a nav link
const navLinks = navbarCollapse.querySelectorAll('.nav-link');
navLinks.forEach(link => {
link.addEventListener('click', () => {
if (window.innerWidth <= 991.98) {
const bsCollapse = new bootstrap.Collapse(navbarCollapse);
bsCollapse.hide();
}
});
});
// Add swipe to close functionality
this.addSwipeToClose(navbarCollapse);
}
}
enhanceModals() {
if (!this.isMobile) return;
// Enhance modal behavior on mobile
const modals = document.querySelectorAll('.modal');
modals.forEach(modal => {
// Add swipe to close functionality
this.addSwipeToClose(modal);
// Improve modal positioning
modal.addEventListener('shown.bs.modal', () => {
this.centerModal(modal);
});
});
}
enhanceForms() {
if (!this.isMobile) return;
// Enhance form inputs for mobile
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.classList.add('mobile-form');
// Add mobile-specific form validation
this.addMobileFormValidation(form);
// Improve form submission on mobile
this.enhanceFormSubmission(form);
});
}
addTouchGestures() {
if (!this.isMobile) return;
// Add swipe gestures for navigation
this.addSwipeNavigation();
// Add pull-to-refresh functionality
this.addPullToRefresh();
// Add touch feedback
this.addTouchFeedback();
}
addSwipeToClose(element) {
let startX = 0;
let startY = 0;
let currentX = 0;
let currentY = 0;
element.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
});
element.addEventListener('touchmove', (e) => {
currentX = e.touches[0].clientX;
currentY = e.touches[0].clientY;
const diffX = startX - currentX;
const diffY = startY - currentY;
// Horizontal swipe to close
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
if (element.classList.contains('navbar-collapse')) {
const bsCollapse = new bootstrap.Collapse(element);
bsCollapse.hide();
} else if (element.classList.contains('modal')) {
const modal = bootstrap.Modal.getInstance(element);
if (modal) modal.hide();
}
}
});
}
addSwipeNavigation() {
let startX = 0;
let startY = 0;
document.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
});
document.addEventListener('touchend', (e) => {
const endX = e.changedTouches[0].clientX;
const endY = e.changedTouches[0].clientY;
const diffX = startX - endX;
const diffY = startY - endY;
// Swipe left/right for navigation (if on specific pages)
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 100) {
this.handleSwipeNavigation(diffX > 0 ? 'left' : 'right');
}
});
}
addPullToRefresh() {
let startY = 0;
let currentY = 0;
let pullDistance = 0;
let isPulling = false;
document.addEventListener('touchstart', (e) => {
if (window.scrollY === 0) {
startY = e.touches[0].clientY;
isPulling = true;
}
});
document.addEventListener('touchmove', (e) => {
if (!isPulling) return;
currentY = e.touches[0].clientY;
pullDistance = currentY - startY;
if (pullDistance > 0 && pullDistance < 100) {
this.showPullToRefreshIndicator(pullDistance);
}
});
document.addEventListener('touchend', () => {
if (isPulling && pullDistance > 80) {
this.refreshPage();
}
this.hidePullToRefreshIndicator();
isPulling = false;
});
}
addTouchFeedback() {
const touchElements = document.querySelectorAll('.btn, .card, .nav-link');
touchElements.forEach(element => {
element.addEventListener('touchstart', () => {
element.style.transform = 'scale(0.98)';
element.style.transition = 'transform 0.1s ease';
});
element.addEventListener('touchend', () => {
element.style.transform = 'scale(1)';
});
});
}
handleSwipeNavigation(direction) {
// Handle swipe navigation based on current page
const currentPath = window.location.pathname;
if (direction === 'left') {
// Swipe left - go forward
if (window.history.length > 1) {
window.history.forward();
}
} else {
// Swipe right - go back
if (window.history.length > 1) {
window.history.back();
}
}
}
showPullToRefreshIndicator(distance) {
// Create or update pull-to-refresh indicator
let indicator = document.getElementById('pull-to-refresh-indicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.id = 'pull-to-refresh-indicator';
indicator.innerHTML = `
<div class="text-center p-3">
<i class="fas fa-arrow-down text-primary"></i>
<span class="ms-2">Pull to refresh</span>
</div>
`;
indicator.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
background: white;
z-index: 9999;
transform: translateY(-100%);
transition: transform 0.3s ease;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
`;
document.body.appendChild(indicator);
}
const translateY = Math.min(distance * 0.5, 100);
indicator.style.transform = `translateY(${translateY - 100}px)`;
}
hidePullToRefreshIndicator() {
const indicator = document.getElementById('pull-to-refresh-indicator');
if (indicator) {
indicator.style.transform = 'translateY(-100%)';
setTimeout(() => {
if (indicator.parentNode) {
indicator.parentNode.removeChild(indicator);
}
}, 300);
}
}
refreshPage() {
// Show loading indicator
this.showLoadingIndicator();
// Refresh the page
setTimeout(() => {
window.location.reload();
}, 500);
}
showLoadingIndicator() {
const loading = document.createElement('div');
loading.id = 'mobile-loading-indicator';
loading.innerHTML = `
<div class="text-center p-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div class="mt-2">Refreshing...</div>
</div>
`;
loading.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
z-index: 10000;
`;
document.body.appendChild(loading);
}
centerModal(modal) {
// Center modal content on mobile
const modalContent = modal.querySelector('.modal-content');
if (modalContent) {
modalContent.style.margin = 'auto';
modalContent.style.maxHeight = '90vh';
modalContent.style.overflow = 'auto';
}
}
addMobileFormValidation(form) {
// Add mobile-specific form validation
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
input.addEventListener('invalid', (e) => {
e.preventDefault();
this.showMobileValidationError(input);
});
input.addEventListener('input', () => {
this.hideMobileValidationError(input);
});
});
}
showMobileValidationError(input) {
// Show mobile-friendly validation error
const errorDiv = document.createElement('div');
errorDiv.className = 'mobile-validation-error';
errorDiv.textContent = input.validationMessage;
errorDiv.style.cssText = `
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
padding: 0.5rem;
background: #fef2f2;
border-radius: 6px;
border: 1px solid #fecaca;
`;
input.parentNode.appendChild(errorDiv);
input.style.borderColor = '#dc2626';
}
hideMobileValidationError(input) {
const errorDiv = input.parentNode.querySelector('.mobile-validation-error');
if (errorDiv) {
errorDiv.remove();
}
input.style.borderColor = '#e2e8f0';
}
enhanceFormSubmission(form) {
// Enhance form submission for mobile
form.addEventListener('submit', (e) => {
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.innerHTML = '<div class="spinner-border spinner-border-sm me-2"></div>Submitting...';
submitBtn.disabled = true;
}
});
}
handleViewportChanges() {
window.addEventListener('resize', () => {
const wasMobile = this.isMobile;
this.isMobile = window.innerWidth <= 768;
if (wasMobile !== this.isMobile) {
if (this.isMobile) {
document.body.classList.add('mobile-view');
this.enhanceTouchTargets();
this.enhanceTables();
} else {
document.body.classList.remove('mobile-view');
}
}
});
}
addMobileSpecificFeatures() {
if (!this.isMobile) return;
// Add mobile-specific features
this.addMobileKeyboardHandling();
this.addMobileScrollOptimization();
this.addMobilePerformanceOptimizations();
}
addMobileKeyboardHandling() {
// Handle mobile keyboard events
const inputs = document.querySelectorAll('input, textarea');
inputs.forEach(input => {
input.addEventListener('focus', () => {
// Scroll to input when focused on mobile
setTimeout(() => {
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 300);
});
});
}
addMobileScrollOptimization() {
// Optimize scrolling on mobile
let ticking = false;
const updateScroll = () => {
// Add scroll-based animations or effects
ticking = false;
};
const requestTick = () => {
if (!ticking) {
requestAnimationFrame(updateScroll);
ticking = true;
}
};
document.addEventListener('scroll', requestTick, { passive: true });
}
addMobilePerformanceOptimizations() {
// Add mobile-specific performance optimizations
// Lazy load images
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
imageObserver.unobserve(img);
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
}
// Optimize animations for mobile
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
if (prefersReducedMotion.matches) {
document.body.style.setProperty('--transition', 'none');
}
}
}
// Initialize mobile enhancements when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new MobileEnhancer();
});
// Export for use in other scripts
window.MobileEnhancer = MobileEnhancer;

View File

@@ -2,7 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="theme-color" content="#3b82f6">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<!-- Favicon -->
@@ -15,6 +19,7 @@
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css') }}">
<style>
:root {
--primary-color: #3b82f6;
@@ -45,6 +50,8 @@
html, body {
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
body {
@@ -56,14 +63,17 @@
display: flex;
flex-direction: column;
font-size: 0.95rem;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
main {
flex: 1 0 auto;
display: block;
padding-bottom: 2rem;
}
/* Navbar Styling */
/* Mobile-First Navigation */
.navbar {
background: white !important;
backdrop-filter: blur(10px);
@@ -72,6 +82,7 @@
padding: 0.75rem 0;
z-index: 1030;
position: relative;
min-height: var(--mobile-nav-height);
}
.navbar-brand {
@@ -99,11 +110,14 @@
.navbar-nav .nav-link {
color: var(--text-secondary) !important;
font-weight: 500;
padding: 0.5rem 1rem;
padding: 0.75rem 1rem;
border-radius: var(--border-radius-sm);
transition: var(--transition);
position: relative;
margin: 0 0.25rem;
min-height: var(--mobile-touch-target);
display: flex;
align-items: center;
}
.navbar-nav .nav-link:hover {
@@ -134,6 +148,9 @@
padding: 0.75rem 1.5rem;
transition: var(--transition);
color: var(--text-secondary);
min-height: var(--mobile-touch-target);
display: flex;
align-items: center;
}
.dropdown-item:hover {
@@ -141,6 +158,60 @@
color: var(--primary-color);
}
/* Mobile Navigation Toggle */
.navbar-toggler {
border: none;
padding: 0.5rem;
min-height: var(--mobile-touch-target);
min-width: var(--mobile-touch-target);
}
.navbar-toggler:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Mobile Navigation Menu */
@media (max-width: 991.98px) {
.navbar-collapse {
background: white;
border-top: 1px solid var(--border-color);
margin-top: 0.5rem;
padding: 1rem 0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.navbar-nav .nav-link {
padding: 1rem 1.5rem;
margin: 0.25rem 0;
border-radius: var(--border-radius-sm);
font-size: 1.1rem;
}
.navbar-nav .nav-link i {
width: 24px;
text-align: center;
margin-right: 0.75rem;
}
.dropdown-menu {
position: static !important;
float: none;
width: 100%;
margin: 0;
border: none;
box-shadow: none;
background: var(--light-color);
border-radius: var(--border-radius-sm);
margin-top: 0.5rem;
}
.dropdown-item {
padding: 0.75rem 2rem;
border-radius: var(--border-radius-sm);
margin: 0.25rem 0.5rem;
}
}
/* Card Styling */
.card {
border: 1px solid var(--border-color);
@@ -198,15 +269,27 @@
padding: 1.5rem;
}
/* Button Styling */
/* Button Styling - Mobile Optimized */
.btn {
border-radius: var(--border-radius-sm);
font-weight: 500;
padding: 0.625rem 1.25rem;
padding: 0.75rem 1.5rem;
transition: var(--transition);
border: none;
position: relative;
font-size: 0.9rem;
font-size: 0.95rem;
min-height: var(--mobile-touch-target);
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
@@ -266,6 +349,21 @@
color: var(--text-primary);
}
/* Mobile Button Sizes */
@media (max-width: 768px) {
.btn {
padding: 1rem 1.5rem;
font-size: 1rem;
min-height: 48px;
}
.btn-sm {
padding: 0.75rem 1.25rem;
font-size: 0.9rem;
min-height: 40px;
}
}
/* Timer Display */
.timer-display {
font-family: 'Inter', monospace;
@@ -300,7 +398,7 @@
margin-bottom: 0.5rem;
}
/* Table Styling */
/* Table Styling - Mobile Responsive */
.table {
border-radius: var(--border-radius-sm);
overflow: hidden;
@@ -334,6 +432,69 @@
background: var(--light-color);
}
/* Mobile Table Responsiveness */
@media (max-width: 768px) {
.table-responsive {
border: none;
}
.table {
display: block;
width: 100%;
}
.table thead {
display: none;
}
.table tbody {
display: block;
width: 100%;
}
.table tr {
display: block;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: white;
box-shadow: var(--card-shadow);
}
.table td {
display: block;
text-align: left;
padding: 0.75rem 1rem;
border: none;
border-bottom: 1px solid var(--border-color);
position: relative;
}
.table td:last-child {
border-bottom: none;
}
.table td:before {
content: attr(data-label) ": ";
font-weight: 600;
color: var(--text-primary);
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.table td.actions-cell {
text-align: center;
padding: 1rem;
}
.table td.actions-cell:before {
display: none;
}
}
/* Badge Styling */
.badge {
font-size: 0.75rem;
@@ -342,14 +503,15 @@
border-radius: var(--border-radius-sm);
}
/* Form Styling */
/* Form Styling - Mobile Optimized */
.form-control, .form-select {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: 0.625rem 0.875rem;
font-size: 0.9rem;
padding: 0.875rem 1rem;
font-size: 1rem;
transition: var(--transition);
background: white;
min-height: var(--mobile-touch-target);
}
.form-control:focus, .form-select:focus {
@@ -361,8 +523,21 @@
.form-label {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
font-size: 0.9rem;
margin-bottom: 0.75rem;
font-size: 0.95rem;
}
/* Mobile Form Improvements */
@media (max-width: 768px) {
.form-control, .form-select {
font-size: 16px; /* Prevents zoom on iOS */
padding: 1rem 1.25rem;
}
.form-label {
font-size: 1rem;
margin-bottom: 0.5rem;
}
}
/* Alert Styling */
@@ -399,7 +574,7 @@
color: #92400e;
}
/* Modal Styling */
/* Modal Styling - Mobile Optimized */
.modal-content {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
@@ -420,6 +595,22 @@
padding: 1.5rem;
}
/* Mobile Modal Improvements */
@media (max-width: 576px) {
.modal-dialog {
margin: 0.5rem;
max-width: calc(100% - 1rem);
}
.modal-content {
border-radius: var(--border-radius);
}
.modal-header, .modal-body, .modal-footer {
padding: 1rem;
}
}
/* Footer */
.footer {
background: white;
@@ -465,7 +656,7 @@
to { transform: rotate(360deg); }
}
/* Responsive Design */
/* Enhanced Mobile Responsiveness */
@media (max-width: 768px) {
.timer-display {
font-size: 1.5rem;
@@ -479,8 +670,82 @@
padding: 1.25rem;
}
.container {
padding-left: 1rem;
padding-right: 1rem;
}
.row {
margin-left: -0.5rem;
margin-right: -0.5rem;
}
.col, .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
/* Mobile-specific spacing */
.mb-4 { margin-bottom: 1.5rem !important; }
.mb-3 { margin-bottom: 1rem !important; }
.mb-2 { margin-bottom: 0.75rem !important; }
.py-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; }
.py-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; }
/* Mobile navigation improvements */
.navbar-nav .nav-link {
border-radius: var(--border-radius-sm);
margin: 0.25rem 0;
}
/* Mobile card improvements */
.card {
margin-bottom: 1rem;
}
/* Mobile button group improvements */
.btn-group {
display: flex;
flex-direction: column;
width: 100%;
}
.btn-group .btn {
border-radius: var(--border-radius-sm) !important;
margin-bottom: 0.5rem;
}
.btn-group .btn:last-child {
margin-bottom: 0;
}
}
/* Small Mobile Devices */
@media (max-width: 480px) {
.navbar-brand {
font-size: 1.2rem;
}
.card-body {
padding: 1rem;
}
.btn {
padding: 0.5rem 1rem;
width: 100%;
margin-bottom: 0.5rem;
}
.btn:last-child {
margin-bottom: 0;
}
.d-flex.justify-content-between {
flex-direction: column;
}
.d-flex.justify-content-between .btn {
margin-bottom: 0.5rem;
}
}
@@ -540,6 +805,16 @@
h5 { font-size: 1.125rem; }
h6 { font-size: 1rem; }
/* Mobile Typography */
@media (max-width: 768px) {
h1 { font-size: 1.75rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; }
h5 { font-size: 1rem; }
h6 { font-size: 0.95rem; }
}
.text-muted {
color: var(--text-muted) !important;
}
@@ -627,6 +902,117 @@
.px-4 { padding-left: 1.5rem !important; padding-right: 1.5rem !important; }
.px-3 { padding-left: 1rem !important; padding-right: 1rem !important; }
/* Mobile-specific improvements */
.mobile-stack {
display: flex;
flex-direction: column;
}
.mobile-stack .btn {
margin-bottom: 0.5rem;
}
.mobile-stack .btn:last-child {
margin-bottom: 0;
}
/* Touch-friendly improvements */
.touch-target {
min-height: var(--mobile-touch-target);
min-width: var(--mobile-touch-target);
}
/* Mobile navigation improvements */
.mobile-nav-item {
padding: 1rem 1.5rem;
border-radius: var(--border-radius-sm);
margin: 0.25rem 0;
transition: var(--transition);
}
.mobile-nav-item:hover {
background: var(--light-color);
}
.mobile-nav-item.active {
background: var(--primary-color);
color: white;
}
/* Mobile form improvements */
.mobile-form-group {
margin-bottom: 1.5rem;
}
.mobile-form-group .form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
.mobile-form-group .form-control,
.mobile-form-group .form-select {
width: 100%;
}
/* Mobile card improvements */
.mobile-card {
margin-bottom: 1rem;
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
}
.mobile-card .card-body {
padding: 1.25rem;
}
/* Mobile button improvements */
.mobile-btn {
width: 100%;
margin-bottom: 0.75rem;
padding: 1rem 1.5rem;
font-size: 1rem;
min-height: 48px;
}
.mobile-btn:last-child {
margin-bottom: 0;
}
/* Mobile table improvements */
.mobile-table-row {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
margin-bottom: 1rem;
padding: 1rem;
box-shadow: var(--card-shadow);
}
.mobile-table-row .row {
margin: 0;
}
.mobile-table-row .col {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.mobile-table-row .col:last-child {
border-bottom: none;
}
.mobile-table-row .col:before {
content: attr(data-label) ": ";
font-weight: 600;
color: var(--text-primary);
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
</style>
{% block extra_css %}{% endblock %}
@@ -640,7 +1026,7 @@
<span class="text-dark fw-bold">Time Tracker</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
@@ -678,7 +1064,7 @@
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
<i class="fas fa-user text-primary"></i>
</div>
@@ -708,7 +1094,7 @@
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show fade-in" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
@@ -743,6 +1129,7 @@
<!-- Socket.IO -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.js"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='mobile.js') }}"></script>
<script>
// Initialize Socket.IO
const socket = io();
@@ -813,6 +1200,62 @@
card.style.animationDelay = `${index * 0.1}s`;
card.classList.add('fade-in');
});
// Mobile-specific improvements
if (window.innerWidth <= 768) {
// Add mobile-specific classes
document.body.classList.add('mobile-view');
// Improve touch targets
const buttons = document.querySelectorAll('.btn');
buttons.forEach(btn => {
btn.classList.add('touch-target');
});
// Improve form inputs
const inputs = document.querySelectorAll('.form-control, .form-select');
inputs.forEach(input => {
input.classList.add('touch-target');
});
}
});
// Handle mobile navigation improvements
document.addEventListener('DOMContentLoaded', function() {
const navbarToggler = document.querySelector('.navbar-toggler');
const navbarCollapse = document.querySelector('.navbar-collapse');
if (navbarToggler && navbarCollapse) {
// Close mobile menu when clicking outside
document.addEventListener('click', function(event) {
const isClickInsideNavbar = navbarToggler.contains(event.target) || navbarCollapse.contains(event.target);
if (!isClickInsideNavbar && navbarCollapse.classList.contains('show')) {
const bsCollapse = new bootstrap.Collapse(navbarCollapse);
bsCollapse.hide();
}
});
// Close mobile menu when clicking on a nav link
const navLinks = navbarCollapse.querySelectorAll('.nav-link');
navLinks.forEach(link => {
link.addEventListener('click', function() {
if (window.innerWidth <= 991.98) {
const bsCollapse = new bootstrap.Collapse(navbarCollapse);
bsCollapse.hide();
}
});
});
}
});
// Handle mobile viewport changes
window.addEventListener('resize', function() {
if (window.innerWidth <= 768) {
document.body.classList.add('mobile-view');
} else {
document.body.classList.remove('mobile-view');
}
});
</script>

View File

@@ -9,17 +9,17 @@
<!-- Welcome Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card mobile-card">
<div class="card-body py-4">
<div class="row align-items-center">
<div class="col-md-8">
<div class="col-lg-8 col-md-7 mb-3 mb-md-0">
<div class="d-flex align-items-center mb-2">
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="DryTrix Logo" class="me-2" width="24" height="24">
<h2 class="mb-0">Welcome back, {{ current_user.username }}!</h2>
</div>
<p class="text-muted mb-0">Track your productivity and manage your time effectively with <strong>DryTrix</strong> TimeTracker</p>
</div>
<div class="col-md-4 text-md-end">
<div class="col-lg-4 col-md-5 text-center text-md-end">
<div class="d-flex justify-content-center justify-content-md-end">
<div class="text-center">
<div class="h2 text-primary mb-1">{{ today_hours|round(1) }}</div>
@@ -36,7 +36,7 @@
<!-- Timer Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card mobile-card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-clock me-2 text-primary"></i>Timer Status
@@ -44,15 +44,15 @@
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<div class="col-lg-8 col-md-7 mb-3 mb-md-0">
{% if active_timer %}
<div class="d-flex align-items-center">
<div class="me-4">
<div class="d-flex align-items-center flex-column flex-md-row">
<div class="me-0 me-md-4 mb-3 mb-md-0">
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center" style="width: 60px; height: 60px;">
<i class="fas fa-play text-success fa-2x"></i>
</div>
</div>
<div>
<div class="text-center text-md-start">
<h5 class="mb-1 text-success">Timer Running</h5>
<p class="mb-2 text-muted">
<i class="fas fa-project-diagram me-1"></i>{{ active_timer.project.name }}
@@ -66,13 +66,13 @@
</div>
</div>
{% else %}
<div class="d-flex align-items-center">
<div class="me-4">
<div class="d-flex align-items-center flex-column flex-md-row">
<div class="me-0 me-md-4 mb-3 mb-md-0">
<div class="bg-light rounded-circle d-flex align-items-center justify-content-center" style="width: 60px; height: 60px;">
<i class="fas fa-stop text-muted fa-2x"></i>
</div>
</div>
<div>
<div class="text-center text-md-start">
<h5 class="mb-1 text-muted">No Active Timer</h5>
<p class="mb-0 text-muted">Ready to start tracking your time?</p>
<div class="mt-2">
@@ -82,15 +82,15 @@
</div>
{% endif %}
</div>
<div class="col-md-4 text-center text-md-end">
<div class="col-lg-4 col-md-5 text-center">
{% if active_timer %}
<form method="POST" action="{{ url_for('timer.stop_timer') }}" class="d-inline">
<button type="submit" class="btn btn-danger px-4">
<form method="POST" action="{{ url_for('timer.stop_timer') }}" class="d-inline w-100">
<button type="submit" class="btn btn-danger px-4 w-100 w-md-auto touch-target">
<i class="fas fa-stop me-2"></i>Stop Timer
</button>
</form>
{% else %}
<button type="button" class="btn btn-success px-4" data-bs-toggle="modal" data-bs-target="#startTimerModal">
<button type="button" class="btn btn-success px-4 w-100 w-md-auto touch-target" data-bs-toggle="modal" data-bs-target="#startTimerModal">
<i class="fas fa-play me-2"></i>Start Timer
</button>
{% endif %}
@@ -103,8 +103,8 @@
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="col-lg-4 col-md-6 mb-3">
<div class="card h-100 mobile-card">
<div class="card-body text-center py-4">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 60px; height: 60px;">
<i class="fas fa-calendar-day text-primary fa-2x"></i>
@@ -117,8 +117,8 @@
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="col-lg-4 col-md-6 mb-3">
<div class="card h-100 mobile-card">
<div class="card-body text-center py-4">
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 60px; height: 60px;">
<i class="fas fa-calendar-week text-success fa-2x"></i>
@@ -131,8 +131,8 @@
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="col-lg-4 col-md-6 mb-3">
<div class="card h-100 mobile-card">
<div class="card-body text-center py-4">
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 60px; height: 60px;">
<i class="fas fa-calendar-alt text-info fa-2x"></i>
@@ -150,7 +150,7 @@
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card mobile-card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-bolt me-2 text-warning"></i>Quick Actions
@@ -158,8 +158,8 @@
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3 col-sm-6">
<a href="{{ url_for('timer.manual_entry') }}" class="card h-100 text-decoration-none">
<div class="col-lg-3 col-md-6 col-sm-6">
<a href="{{ url_for('timer.manual_entry') }}" class="card h-100 text-decoration-none mobile-card">
<div class="card-body text-center py-4">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 50px; height: 50px;">
<i class="fas fa-plus text-primary fa-lg"></i>
@@ -169,8 +169,8 @@
</div>
</a>
</div>
<div class="col-md-3 col-sm-6">
<a href="{{ url_for('projects.list_projects') }}" class="card h-100 text-decoration-none">
<div class="col-lg-3 col-md-6 col-sm-6">
<a href="{{ url_for('projects.list_projects') }}" class="card h-100 text-decoration-none mobile-card">
<div class="card-body text-center py-4">
<div class="bg-secondary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 50px; height: 50px;">
<i class="fas fa-project-diagram text-secondary fa-lg"></i>
@@ -180,8 +180,8 @@
</div>
</a>
</div>
<div class="col-md-3 col-sm-6">
<a href="{{ url_for('reports.reports') }}" class="card h-100 text-decoration-none">
<div class="col-lg-3 col-md-6 col-sm-6">
<a href="{{ url_for('reports.reports') }}" class="card h-100 text-decoration-none mobile-card">
<div class="card-body text-center py-4">
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 50px; height: 50px;">
<i class="fas fa-chart-bar text-info fa-lg"></i>
@@ -191,8 +191,8 @@
</div>
</a>
</div>
<div class="col-md-3 col-sm-6">
<a href="{{ url_for('main.search') }}" class="card h-100 text-decoration-none">
<div class="col-lg-3 col-md-6 col-sm-6">
<a href="{{ url_for('main.search') }}" class="card h-100 text-decoration-none mobile-card">
<div class="card-body text-center py-4">
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 50px; height: 50px;">
<i class="fas fa-search text-warning fa-lg"></i>
@@ -211,12 +211,12 @@
<!-- Recent Entries -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<div class="card mobile-card">
<div class="card-header d-flex justify-content-between align-items-center flex-column flex-md-row">
<h5 class="mb-0 mb-3 mb-md-0">
<i class="fas fa-history me-2 text-primary"></i>Recent Entries
</h5>
<a href="{{ url_for('reports.reports') }}" class="btn btn-sm btn-outline-primary">
<a href="{{ url_for('reports.reports') }}" class="btn btn-sm btn-outline-primary touch-target">
<i class="fas fa-external-link-alt me-1"></i>View All
</a>
</div>
@@ -236,7 +236,7 @@
<tbody>
{% for entry in recent_entries %}
<tr>
<td class="ps-4">
<td class="ps-4" data-label="Project">
<div class="d-flex align-items-center">
<div class="me-3">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center" style="width: 36px; height: 36px;">
@@ -249,16 +249,16 @@
</div>
</div>
</td>
<td>
<td data-label="Duration">
<span class="badge bg-primary">{{ entry.duration_formatted }}</span>
</td>
<td>
<td data-label="Date">
<div class="d-flex flex-column">
<span class="fw-semibold">{{ entry.start_time.strftime('%b %d') }}</span>
<small class="text-muted">{{ entry.start_time.strftime('%H:%M') }}</small>
</div>
</td>
<td>
<td data-label="Notes">
{% if entry.notes %}
<div class="text-truncate" style="max-width: 200px;" title="{{ entry.notes }}">
{{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}
@@ -267,20 +267,20 @@
<span class="text-muted fst-italic">No notes</span>
{% endif %}
</td>
<td class="text-end pe-4">
<div class="btn-group" role="group">
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit entry">
<i class="fas fa-edit"></i>
</a>
{% if current_user.is_admin or entry.user_id == current_user.id %}
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete entry"
onclick="showDeleteEntryModal('{{ entry.id }}', '{{ entry.project.name }}', '{{ entry.duration_formatted }}')">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</td>
<td class="text-end pe-4 actions-cell" data-label="Actions">
<div class="btn-group d-flex d-md-inline-flex mobile-stack" role="group">
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}"
class="btn btn-sm btn-outline-secondary touch-target" title="Edit entry">
<i class="fas fa-edit"></i>
</a>
{% if current_user.is_admin or entry.user_id == current_user.id %}
<button type="button" class="btn btn-sm btn-outline-danger touch-target" title="Delete entry"
onclick="showDeleteEntryModal('{{ entry.id }}', '{{ entry.project.name }}', '{{ entry.duration_formatted }}')">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
@@ -293,7 +293,7 @@
</div>
<h5 class="text-muted mb-3">No recent entries</h5>
<p class="text-muted mb-4">Start tracking your time to see entries here</p>
<a href="{{ url_for('timer.manual_entry') }}" class="btn btn-primary">
<a href="{{ url_for('timer.manual_entry') }}" class="btn btn-primary touch-target">
<i class="fas fa-plus me-2"></i>Log Your First Entry
</a>
</div>
@@ -311,7 +311,7 @@
<h5 class="modal-title">
<i class="fas fa-play me-2 text-success"></i>Start Timer
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" action="{{ url_for('timer.start_timer') }}">
<div class="modal-body">
@@ -336,8 +336,8 @@
placeholder="What are you working on?"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<div class="modal-footer d-flex flex-column flex-md-row">
<button type="button" class="btn btn-secondary mb-2 mb-md-0 me-md-2" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Cancel
</button>
<button type="submit" class="btn btn-success">
@@ -357,7 +357,7 @@
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>Delete Time Entry
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
@@ -367,8 +367,8 @@
<p>Are you sure you want to delete the time entry for <strong id="deleteEntryProjectName"></strong>?</p>
<p class="text-muted mb-0">Duration: <strong id="deleteEntryDuration"></strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<div class="modal-footer d-flex flex-column flex-md-row">
<button type="button" class="btn btn-secondary mb-2 mb-md-0 me-md-2" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Cancel
</button>
<form method="POST" id="deleteEntryForm" class="d-inline">
@@ -454,6 +454,28 @@
this.style.transform = 'translateY(0) scale(1)';
});
});
// Mobile-specific improvements
if (window.innerWidth <= 768) {
// Improve mobile table responsiveness
const tableRows = document.querySelectorAll('tbody tr');
tableRows.forEach(row => {
const cells = row.querySelectorAll('td');
cells.forEach((cell, index) => {
if (index === 0) {
cell.setAttribute('data-label', 'Project');
} else if (index === 1) {
cell.setAttribute('data-label', 'Duration');
} else if (index === 2) {
cell.setAttribute('data-label', 'Date');
} else if (index === 3) {
cell.setAttribute('data-label', 'Notes');
} else if (index === 4) {
cell.setAttribute('data-label', 'Actions');
}
});
});
}
});
// Function to show delete time entry modal
@@ -475,5 +497,29 @@
});
}
});
// Handle mobile viewport changes
window.addEventListener('resize', function() {
if (window.innerWidth <= 768) {
// Re-apply mobile table improvements
const tableRows = document.querySelectorAll('tbody tr');
tableRows.forEach(row => {
const cells = row.querySelectorAll('td');
cells.forEach((cell, index) => {
if (index === 0) {
cell.setAttribute('data-label', 'Project');
} else if (index === 1) {
cell.setAttribute('data-label', 'Duration');
} else if (index === 2) {
cell.setAttribute('data-label', 'Date');
} else if (index === 3) {
cell.setAttribute('data-label', 'Notes');
} else if (index === 4) {
cell.setAttribute('data-label', 'Actions');
}
});
});
}
});
</script>
{% endblock %}

76
docker-compose.yml Normal file
View File

@@ -0,0 +1,76 @@
services:
app:
build: .
container_name: timetracker-app
environment:
- TZ=${TZ:-Europe/Brussels}
- CURRENCY=${CURRENCY:-EUR}
- ROUNDING_MINUTES=${ROUNDING_MINUTES:-1}
- SINGLE_ACTIVE_TIMER=${SINGLE_ACTIVE_TIMER:-true}
- ALLOW_SELF_REGISTER=${ALLOW_SELF_REGISTER:-true}
- IDLE_TIMEOUT_MINUTES=${IDLE_TIMEOUT_MINUTES:-30}
- ADMIN_USERNAMES=${ADMIN_USERNAMES:-admin}
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
- DATABASE_URL=postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker
- LOG_FILE=/app/logs/timetracker.log
ports:
- "8080:8080"
volumes:
- app_data:/data
- ./logs:/app/logs
depends_on:
db:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/_health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
image: postgres:16-alpine
container_name: timetracker-db
environment:
- POSTGRES_DB=${POSTGRES_DB:-timetracker}
- POSTGRES_USER=${POSTGRES_USER:-timetracker}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-timetracker}
- TZ=${TZ:-Europe/Brussels}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# Optional reverse proxy for TLS on LAN
proxy:
image: caddy:2-alpine
container_name: timetracker-proxy
volumes:
- ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
ports:
- "80:80"
- "443:443"
depends_on:
app:
condition: service_healthy
restart: unless-stopped
profiles:
- tls
volumes:
app_data:
driver: local
db_data:
driver: local
caddy_data:
driver: local
caddy_config:
driver: local

View File

@@ -6,13 +6,13 @@
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<div class="d-flex justify-content-between align-items-center mb-4 flex-column flex-md-row">
<h1 class="h3 mb-0 mb-3 mb-md-0">
<i class="fas fa-project-diagram text-primary"></i> Projects
</h1>
{% if current_user.is_admin %}
<div>
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary">
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary touch-target">
<i class="fas fa-plus"></i> New Project
</a>
</div>
@@ -24,41 +24,41 @@
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card mobile-card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-filter me-2 text-primary"></i>Filters
</h6>
</div>
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-4">
<form method="GET" class="row g-3 mobile-form">
<div class="col-12 col-md-4 mobile-form-group">
<label for="status" class="form-label">Status</label>
<select class="form-select" id="status" name="status">
<select class="form-select touch-target" id="status" name="status">
<option value="">All Statuses</option>
<option value="active" {% if request.args.get('status') == 'active' %}selected{% endif %}>Active</option>
<option value="archived" {% if request.args.get('status') == 'archived' %}selected{% endif %}>Archived</option>
</select>
</div>
<div class="col-md-4">
<div class="col-12 col-md-4 mobile-form-group">
<label for="client" class="form-label">Client</label>
<select class="form-select" id="client" name="client">
<select class="form-select touch-target" id="client" name="client">
<option value="">All Clients</option>
{% for client in clients %}
<option value="{{ client }}" {% if request.args.get('client') == client %}selected{% endif %}>{{ client }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<div class="col-12 col-md-4 mobile-form-group">
<label for="search" class="form-label">Search</label>
<input type="text" class="form-control" id="search" name="search"
<input type="text" class="form-control touch-target" id="search" name="search"
value="{{ request.args.get('search', '') }}" placeholder="Project name or description">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">
<div class="col-12 d-flex flex-column flex-md-row gap-2 mobile-stack">
<button type="submit" class="btn btn-primary mobile-btn">
<i class="fas fa-search me-1"></i>Filter
</button>
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-outline-secondary">
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-outline-secondary mobile-btn">
<i class="fas fa-times me-1"></i>Clear
</a>
</div>
@@ -71,7 +71,7 @@
<!-- Projects List -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card mobile-card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-list"></i> Projects ({{ projects|length }})
@@ -95,7 +95,7 @@
<tbody>
{% for project in projects %}
<tr>
<td>
<td data-label="Project">
<div>
<strong>{{ project.name }}</strong>
{% if project.description %}
@@ -103,65 +103,44 @@
{% endif %}
</div>
</td>
<td>
{% if project.client %}
<a href="{{ url_for('projects.view_client', client_name=project.client) }}" class="text-decoration-none">
{{ project.client }}
</a>
{% else %}
<span class="text-muted">-</span>
{% endif %}
<td data-label="Client">
<span class="badge bg-secondary">{{ project.client }}</span>
</td>
<td>
<td data-label="Status">
{% if project.status == 'active' %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Archived</span>
{% endif %}
</td>
<td>
<strong>{{ "%.1f"|format(project.total_hours) }}h</strong>
<td data-label="Total Hours">
<strong>{{ "%.1f"|format(project.total_hours or 0) }}</strong>
</td>
<td>
{% if project.billable %}
<span class="text-success">{{ "%.1f"|format(project.total_billable_hours) }}h</span>
<td data-label="Billable Hours">
<span class="text-success">{{ "%.1f"|format(project.billable_hours or 0) }}</span>
</td>
<td data-label="Rate">
{% if project.hourly_rate %}
<span class="text-primary">${{ "%.2f"|format(project.hourly_rate) }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if project.billable and project.hourly_rate %}
<span class="text-success">{{ currency }} {{ "%.2f"|format(project.hourly_rate) }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
<td class="actions-cell" data-label="Actions">
<div class="btn-group d-flex d-md-inline-flex mobile-stack" role="group">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
class="btn btn-sm btn-outline-primary" title="View">
class="btn btn-sm btn-outline-primary touch-target" title="View project">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit">
class="btn btn-sm btn-outline-secondary touch-target" title="Edit project">
<i class="fas fa-edit"></i>
</a>
{% if project.status == 'active' %}
<button type="button" class="btn btn-sm btn-outline-warning" title="Archive"
onclick="showArchiveModal('{{ project.id }}', '{{ project.name }}')">
<i class="fas fa-archive"></i>
</button>
{% else %}
<button type="button" class="btn btn-sm btn-outline-success" title="Unarchive"
onclick="showUnarchiveModal('{{ project.id }}', '{{ project.name }}')">
<i class="fas fa-box-open"></i>
</button>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete"
onclick="showDeleteModal('{{ project.id }}', '{{ project.name }}')">
<i class="fas fa-trash"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger touch-target" title="Delete project"
onclick="showDeleteProjectModal('{{ project.id }}', '{{ project.name }}')">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</td>
@@ -172,23 +151,14 @@
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-project-diagram fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Projects Found</h4>
<p class="text-muted">
{% if request.args.get('status') or request.args.get('client') or request.args.get('search') %}
Try adjusting your filters or
<a href="{{ url_for('projects.list_projects') }}">view all projects</a>.
{% else %}
{% if current_user.is_admin %}
Get started by creating your first project.
{% else %}
No projects have been created yet.
{% endif %}
{% endif %}
</p>
{% if current_user.is_admin and not (request.args.get('status') or request.args.get('client') or request.args.get('search')) %}
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Create First Project
<div class="mb-4">
<i class="fas fa-project-diagram fa-3x text-muted opacity-50"></i>
</div>
<h5 class="text-muted mb-3">No projects found</h5>
<p class="text-muted mb-4">Create your first project to get started</p>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary touch-target">
<i class="fas fa-plus me-2"></i>Create Project
</a>
{% endif %}
</div>
@@ -199,62 +169,6 @@
</div>
</div>
<!-- Archive Project Modal -->
<div class="modal fade" id="archiveProjectModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-archive me-2 text-warning"></i>Archive Project
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to archive the project <strong id="archiveProjectName"></strong>?</p>
<p class="text-muted mb-0">Archived projects will be hidden from the main project list but can be restored later.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Cancel
</button>
<form method="POST" id="archiveProjectForm" class="d-inline">
<button type="submit" class="btn btn-warning">
<i class="fas fa-archive me-2"></i>Archive Project
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Unarchive Project Modal -->
<div class="modal fade" id="unarchiveProjectModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-box-open me-2 text-success"></i>Restore Project
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to restore the project <strong id="unarchiveProjectName"></strong>?</p>
<p class="text-muted mb-0">This will make the project active again and visible in the main project list.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Cancel
</button>
<form method="POST" id="unarchiveProjectForm" class="d-inline">
<button type="submit" class="btn btn-success">
<i class="fas fa-box-open me-2"></i>Restore Project
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Delete Project Modal -->
<div class="modal fade" id="deleteProjectModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
@@ -263,22 +177,22 @@
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>Delete Project
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Warning:</strong> This action cannot be undone.
</div>
<p>Are you sure you want to permanently delete the project <strong id="deleteProjectName"></strong>?</p>
<p class="text-muted mb-0">This will also delete all associated time entries and cannot be recovered.</p>
<p>Are you sure you want to delete the project <strong id="deleteProjectName"></strong>?</p>
<p class="text-muted mb-0">All associated time entries will also be deleted.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<div class="modal-footer d-flex flex-column flex-md-row">
<button type="button" class="btn btn-secondary mb-2 mb-md-0 me-md-2 touch-target" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Cancel
</button>
<form method="POST" id="deleteProjectForm" class="d-inline">
<button type="submit" class="btn btn-danger">
<button type="submit" class="btn btn-danger touch-target">
<i class="fas fa-trash me-2"></i>Delete Project
</button>
</form>
@@ -288,49 +202,90 @@
</div>
<script>
// Function to show archive project modal
function showArchiveModal(projectId, projectName) {
document.getElementById('archiveProjectName').textContent = projectName;
document.getElementById('archiveProjectForm').action = "{{ url_for('projects.archive_project', project_id=0) }}".replace('0', projectId);
new bootstrap.Modal(document.getElementById('archiveProjectModal')).show();
}
// Function to show unarchive project modal
function showUnarchiveModal(projectId, projectName) {
document.getElementById('unarchiveProjectName').textContent = projectName;
document.getElementById('unarchiveProjectForm').action = "{{ url_for('projects.unarchive_project', project_id=0) }}".replace('0', projectId);
new bootstrap.Modal(document.getElementById('unarchiveProjectModal')).show();
}
document.addEventListener('DOMContentLoaded', function() {
// Mobile-specific improvements
if (window.innerWidth <= 768) {
// Improve mobile table responsiveness
const tableRows = document.querySelectorAll('tbody tr');
tableRows.forEach(row => {
const cells = row.querySelectorAll('td');
cells.forEach((cell, index) => {
if (index === 0) {
cell.setAttribute('data-label', 'Project');
} else if (index === 1) {
cell.setAttribute('data-label', 'Client');
} else if (index === 2) {
cell.setAttribute('data-label', 'Status');
} else if (index === 3) {
cell.setAttribute('data-label', 'Total Hours');
} else if (index === 4) {
cell.setAttribute('data-label', 'Billable Hours');
} else if (index === 5) {
cell.setAttribute('data-label', 'Rate');
} else if (index === 6) {
cell.setAttribute('data-label', 'Actions');
}
});
});
// Improve touch targets
const inputs = document.querySelectorAll('.form-control, .form-select');
inputs.forEach(input => {
input.classList.add('touch-target');
});
const buttons = document.querySelectorAll('.btn');
buttons.forEach(btn => {
btn.classList.add('touch-target');
});
}
// Handle mobile viewport changes
window.addEventListener('resize', function() {
if (window.innerWidth <= 768) {
// Re-apply mobile table improvements
const tableRows = document.querySelectorAll('tbody tr');
tableRows.forEach(row => {
const cells = row.querySelectorAll('td');
cells.forEach((cell, index) => {
if (index === 0) {
cell.setAttribute('data-label', 'Project');
} else if (index === 1) {
cell.setAttribute('data-label', 'Client');
} else if (index === 2) {
cell.setAttribute('data-label', 'Status');
} else if (index === 3) {
cell.setAttribute('data-label', 'Total Hours');
} else if (index === 4) {
cell.setAttribute('data-label', 'Billable Hours');
} else if (index === 5) {
cell.setAttribute('data-label', 'Rate');
} else if (index === 6) {
cell.setAttribute('data-label', 'Actions');
}
});
});
}
});
});
// Function to show delete project modal
function showDeleteModal(projectId, projectName) {
function showDeleteProjectModal(projectId, projectName) {
document.getElementById('deleteProjectName').textContent = projectName;
document.getElementById('deleteProjectForm').action = "{{ url_for('projects.delete_project', project_id=0) }}".replace('0', projectId);
new bootstrap.Modal(document.getElementById('deleteProjectModal')).show();
}
// Add loading states to form submissions
// Add loading state to delete project form
document.addEventListener('DOMContentLoaded', function() {
// Archive form
document.getElementById('archiveProjectForm').addEventListener('submit', function(e) {
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Archiving...';
submitBtn.disabled = true;
});
// Unarchive form
document.getElementById('unarchiveProjectForm').addEventListener('submit', function(e) {
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Restoring...';
submitBtn.disabled = true;
});
// Delete form
document.getElementById('deleteProjectForm').addEventListener('submit', function(e) {
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Deleting...';
submitBtn.disabled = true;
});
const deleteForm = document.getElementById('deleteProjectForm');
if (deleteForm) {
deleteForm.addEventListener('submit', function(e) {
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Deleting...';
submitBtn.disabled = true;
});
}
});
</script>
{% endblock %}

View File

@@ -5,7 +5,7 @@
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="col-lg-8 col-md-10">
<div class="card">
<div class="card-header d-flex align-items-center">
<i class="fas fa-plus me-2 text-primary"></i>
@@ -26,8 +26,8 @@
</select>
</div>
<div class="row">
<div class="col-md-6">
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="mb-4">
<label class="form-label fw-semibold">
<i class="fas fa-play me-1"></i>Start *
@@ -42,7 +42,7 @@
</div>
</div>
</div>
<div class="col-md-6">
<div class="col-12 col-md-6">
<div class="mb-4">
<label class="form-label fw-semibold">
<i class="fas fa-stop me-1"></i>End *
@@ -66,8 +66,8 @@
<textarea class="form-control" id="notes" name="notes" rows="3" placeholder="What did you work on?">{{ request.form.get('notes','') }}</textarea>
</div>
<div class="row">
<div class="col-md-8">
<div class="row g-3">
<div class="col-12 col-md-8">
<div class="mb-4">
<label for="tags" class="form-label fw-semibold">
<i class="fas fa-tags me-1"></i>Tags
@@ -76,8 +76,8 @@
<div class="form-text">Separate tags with commas</div>
</div>
</div>
<div class="col-md-4 d-flex align-items-center">
<div class="form-check form-switch mt-4">
<div class="col-12 col-md-4 d-flex align-items-center">
<div class="form-check form-switch mt-4 w-100">
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if request.form.get('billable') %}checked{% else %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="billable">
<i class="fas fa-dollar-sign me-1"></i>Billable
@@ -86,8 +86,8 @@
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">
<div class="d-flex justify-content-between flex-column flex-md-row">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary mb-2 mb-md-0">
<i class="fas fa-arrow-left me-1"></i>Back
</a>
<button type="submit" class="btn btn-primary">
@@ -100,6 +100,55 @@
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Set default dates to today
const today = new Date().toISOString().split('T')[0];
const now = new Date().toTimeString().slice(0, 5);
if (!document.getElementById('start_date').value) {
document.getElementById('start_date').value = today;
}
if (!document.getElementById('end_date').value) {
document.getElementById('end_date').value = today;
}
if (!document.getElementById('start_time').value) {
document.getElementById('start_time').value = now;
}
if (!document.getElementById('end_time').value) {
document.getElementById('end_time').value = now;
}
// Mobile-specific improvements
if (window.innerWidth <= 768) {
// Add mobile-specific classes
const form = document.querySelector('form');
form.classList.add('mobile-form');
// Improve touch targets
const inputs = document.querySelectorAll('.form-control, .form-select');
inputs.forEach(input => {
input.classList.add('touch-target');
});
// Improve buttons
const buttons = document.querySelectorAll('.btn');
buttons.forEach(btn => {
btn.classList.add('touch-target');
});
}
// Handle mobile viewport changes
window.addEventListener('resize', function() {
if (window.innerWidth <= 768) {
document.body.classList.add('mobile-view');
} else {
document.body.classList.remove('mobile-view');
}
});
});
</script>
{% endblock %}