Improve mobile UI/UX.

This commit is contained in:
2025-07-13 10:52:20 +02:00
parent 2d18849267
commit 7140aeba41
21 changed files with 3604 additions and 47 deletions

57
app.py
View File

@@ -290,6 +290,63 @@ def setup():
"""Company setup route - delegates to imported function""" """Company setup route - delegates to imported function"""
return company_setup() 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 = '<?xml version="1.0" encoding="UTF-8"?>\n'
sitemap_xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for page in pages:
sitemap_xml += ' <url>\n'
sitemap_xml += f' <loc>{page["loc"]}</loc>\n'
sitemap_xml += f' <lastmod>{page["lastmod"]}</lastmod>\n'
sitemap_xml += f' <changefreq>{page["changefreq"]}</changefreq>\n'
sitemap_xml += f' <priority>{page["priority"]}</priority>\n'
sitemap_xml += ' </url>\n'
sitemap_xml += '</urlset>'
return Response(sitemap_xml, mimetype='application/xml')
@app.route('/') @app.route('/')
def home(): def home():
if g.user: if g.user:

View File

@@ -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 */
}

495
static/css/mobile-forms.css Normal file
View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

173
static/js/date-formatter.js Normal file
View File

@@ -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
};
})();

View File

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

View File

@@ -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 = '<i class="ti ti-chevron-right"></i>';
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 = `
<style id="gesture-styles">
/* Swipe back indicator */
.swipe-back-indicator {
position: fixed;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
background: rgba(0,0,0,0.8);
border-radius: 0 20px 20px 0;
display: flex;
align-items: center;
justify-content: center;
color: white;
opacity: 0;
transition: opacity 0.2s;
z-index: 1000;
pointer-events: none;
}
.swipe-back-indicator.visible {
opacity: 1;
}
/* Swipe actions */
[data-swipe-actions] {
position: relative;
overflow: hidden;
}
[data-swipe-actions]::before,
[data-swipe-actions]::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 100px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
[data-swipe-actions]::before {
left: 0;
background: linear-gradient(to right, #4CAF50, transparent);
}
[data-swipe-actions]::after {
right: 0;
background: linear-gradient(to left, #F44336, transparent);
}
[data-swipe-actions].swipe-right::before,
[data-swipe-actions].swipe-left::after {
opacity: 0.3;
}
/* Context menu */
.mobile-context-menu {
position: fixed;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
padding: 8px 0;
z-index: 1000;
min-width: 150px;
transform: scale(0.8);
animation: contextMenuIn 0.2s ease forwards;
}
@keyframes contextMenuIn {
to {
transform: scale(1);
}
}
.mobile-context-menu button {
display: block;
width: 100%;
padding: 12px 16px;
border: none;
background: none;
text-align: left;
font-size: 16px;
cursor: pointer;
}
.mobile-context-menu button:hover {
background: #f5f5f5;
}
/* Pinch zoom */
[data-zoomable] {
touch-action: pinch-zoom;
transition: transform 0.3s ease;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
addGestureStyles();
new MobileGestures();
});
window.MobileGestures = MobileGestures;
})();

View File

@@ -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
};
})();

View File

@@ -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 = `
<div class="pull-refresh-spinner">
<i class="ti ti-refresh"></i>
</div>
<div class="pull-refresh-text">Pull to refresh</div>
`;
// 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 = `
<style id="pull-refresh-styles">
.pull-refresh-indicator {
position: absolute;
top: -60px;
left: 0;
right: 0;
height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transform: scale(0.8);
transition: opacity 0.2s, transform 0.2s;
}
.pull-refresh-indicator.visible {
opacity: 1;
transform: scale(1);
}
.pull-refresh-indicator.refreshing {
position: fixed;
top: 20px;
z-index: 1000;
}
.pull-refresh-spinner {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.pull-refresh-spinner i {
font-size: 24px;
color: var(--primary-color, #667eea);
transition: transform 0.3s ease;
}
.pull-refresh-indicator.pulling .pull-refresh-spinner i {
transform: rotate(180deg);
}
.pull-refresh-indicator.refreshing .pull-refresh-spinner i {
animation: spin 1s linear infinite;
}
.pull-refresh-text {
margin-top: 4px;
font-size: 12px;
color: var(--text-muted, #999);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Container adjustments */
.content {
position: relative;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.content.pulling {
overflow-y: hidden;
}
</style>
`;
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;
})();

355
static/js/mobile-tables.js Normal file
View File

@@ -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 = '<i class="ti ti-arrow-right"></i> 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 = `
<style id="mobile-table-styles">
.table-card {
background: var(--bg-light);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.table-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.table-card-header {
font-weight: 600;
font-size: 16px;
margin-bottom: 12px;
color: var(--text-primary);
}
.table-card-body {
display: grid;
gap: 8px;
}
.table-card-field {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.table-card-label {
font-size: 13px;
color: var(--text-muted);
flex-shrink: 0;
margin-right: 12px;
}
.table-card-value {
text-align: right;
font-size: 14px;
color: var(--text-secondary);
}
.scroll-indicator {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
background: linear-gradient(to right, transparent, rgba(255,255,255,0.9));
padding: 8px 16px 8px 32px;
pointer-events: none;
transition: opacity 0.3s ease;
font-size: 13px;
color: var(--text-muted);
}
/* Swipe action styles */
.swipe-delete {
background-color: rgba(255, 59, 48, 0.1);
}
.swipe-edit {
background-color: rgba(52, 199, 89, 0.1);
}
/* Mobile compact view */
.mobile-compact td {
padding: 8px 6px;
font-size: 13px;
}
.mobile-compact .btn-sm {
padding: 4px 8px;
font-size: 12px;
}
</style>
`;
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
};
});

View File

@@ -3,7 +3,7 @@ document.addEventListener('DOMContentLoaded', function() {
const sidebar = document.getElementById('sidebar'); const sidebar = document.getElementById('sidebar');
const sidebarToggle = document.getElementById('sidebar-toggle'); const sidebarToggle = document.getElementById('sidebar-toggle');
const mobileNavToggle = document.getElementById('mobile-nav-toggle'); const mobileNavToggle = document.getElementById('mobile-nav-toggle');
const mobileOverlay = document.getElementById('mobile-overlay'); const mobileOverlay = document.getElementById('mobile-nav-overlay');
// Desktop sidebar toggle // Desktop sidebar toggle
if (sidebarToggle) { if (sidebarToggle) {
@@ -27,18 +27,20 @@ document.addEventListener('DOMContentLoaded', function() {
// Mobile navigation toggle // Mobile navigation toggle
if (mobileNavToggle) { if (mobileNavToggle) {
mobileNavToggle.addEventListener('click', function() { mobileNavToggle.addEventListener('click', function() {
sidebar.classList.toggle('mobile-open'); sidebar.classList.toggle('active');
mobileOverlay.classList.toggle('active'); mobileOverlay.classList.toggle('active');
mobileNavToggle.classList.toggle('active'); mobileNavToggle.classList.toggle('active');
document.body.classList.toggle('mobile-nav-open');
}); });
} }
// Close mobile sidebar when clicking overlay // Close mobile sidebar when clicking overlay
if (mobileOverlay) { if (mobileOverlay) {
mobileOverlay.addEventListener('click', function() { mobileOverlay.addEventListener('click', function() {
sidebar.classList.remove('mobile-open'); sidebar.classList.remove('active');
mobileOverlay.classList.remove('active'); mobileOverlay.classList.remove('active');
if (mobileNavToggle) mobileNavToggle.classList.remove('active'); if (mobileNavToggle) mobileNavToggle.classList.remove('active');
document.body.classList.remove('mobile-nav-open');
}); });
} }

101
static/manifest.json Normal file
View File

@@ -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": []
}

View File

@@ -220,7 +220,7 @@
<tr data-entry-id="{{ entry.id }}" class="entry-row"> <tr data-entry-id="{{ entry.id }}" class="entry-row">
<td> <td>
<div class="date-cell"> <div class="date-cell">
<span class="date-day">{{ entry.arrival_time.strftime('%d') }}</span> <span class="date-day">{{ entry.arrival_time.day }}</span>
<span class="date-month">{{ entry.arrival_time.strftime('%b') }}</span> <span class="date-month">{{ entry.arrival_time.strftime('%b') }}</span>
</div> </div>
</td> </td>
@@ -304,7 +304,7 @@
<div class="entry-card" data-entry-id="{{ entry.id }}"> <div class="entry-card" data-entry-id="{{ entry.id }}">
<div class="entry-header"> <div class="entry-header">
<div class="entry-date"> <div class="entry-date">
{{ entry.arrival_time.strftime('%d %b %Y') }} {{ entry.arrival_time|format_date }}
</div> </div>
<div class="entry-duration"> <div class="entry-duration">
{{ entry.duration|format_duration if entry.duration is not none else 'Active' }} {{ entry.duration|format_duration if entry.duration is not none else 'Active' }}

View File

@@ -114,7 +114,7 @@
<span class="info-icon"><i class="ti ti-calendar"></i></span> <span class="info-icon"><i class="ti ti-calendar"></i></span>
<div class="info-content"> <div class="info-content">
<label class="info-label">Created</label> <label class="info-label">Created</label>
<span class="info-value">{{ company.created_at.strftime('%B %d, %Y at %I:%M %p') }}</span> <span class="info-value">{{ company.created_at|format_datetime }}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -240,8 +240,8 @@
</td> </td>
<td class="date-cell"> <td class="date-cell">
{% if project.start_date %} {% if project.start_date %}
{{ project.start_date.strftime('%Y-%m-%d') }} {{ project.start_date|format_date }}
{% if project.end_date %}<br>to {{ project.end_date.strftime('%Y-%m-%d') }}{% endif %} {% if project.end_date %}<br>to {{ project.end_date|format_date }}{% endif %}
{% else %} {% else %}
- -
{% endif %} {% endif %}

View File

@@ -12,7 +12,7 @@
<p class="section-description"> <p class="section-description">
These policies are set by your administrator and apply to all employees. 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 %} {% if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN %}
<a href="{{ url_for('admin_work_policies') }}">Click here to modify these settings</a>. <a href="{{ url_for('companies.admin_company') }}">Click here to modify these settings</a>.
{% endif %} {% endif %}
</p> </p>
@@ -25,20 +25,12 @@
</div> </div>
<div class="policy-item"> <div class="policy-item">
<strong>Break Policy:</strong> <strong>Break Policy:</strong>
{% 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 {{ company_config.break_duration_minutes }} minutes after {{ company_config.break_after_hours }} hours
{% else %} {% else %}
No mandatory breaks No mandatory breaks
{% endif %} {% endif %}
</div> </div>
<div class="policy-item">
<strong>Additional Break:</strong>
{% 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 %}
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@@ -1,5 +1,64 @@
{% extends "layout.html" %} {% 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 %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "{{ g.branding.app_name if g.branding else 'TimeTrack' }}",
"url": "{{ request.host_url }}",
"description": "Free enterprise time tracking software with project management, team collaboration, billing & invoicing.",
"potentialAction": {
"@type": "SearchAction",
"@context": "https://schema.org",
"target": "{{ request.host_url }}search?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is TimeTrack?",
"acceptedAnswer": {
"@type": "Answer",
"text": "TimeTrack is a comprehensive, open-source time tracking and project management software that helps teams track work hours, manage projects, collaborate, and handle billing/invoicing - all in one platform."
}
},
{
"@type": "Question",
"name": "Is TimeTrack really free?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes! TimeTrack is 100% free and open-source. You can use our hosted version at no cost or self-host it on your own servers. There are no hidden fees, no user limits, and all features are included."
}
},
{
"@type": "Question",
"name": "What features does TimeTrack include?",
"acceptedAnswer": {
"@type": "Answer",
"text": "TimeTrack includes one-click time tracking, project management, sprint planning, team collaboration, billing & invoicing, custom reporting, multi-company support, two-factor authentication, and GDPR compliance features."
}
}
]
}
</script>
{% endblock %}
{% block content %} {% block content %}
{% if not g.user %} {% if not g.user %}

View File

@@ -3,11 +3,47 @@
<head> <head>
<meta charset="UTF-8"> <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">
<title>{{ title }} - {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% if g.company %} - {{ g.company.name }}{% endif %}</title> <title>{% 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 %}</title>
<!-- SEO Meta Tags -->
<meta name="description" content="{% block meta_description %}{{ g.branding.app_name if g.branding else 'TimeTrack' }} is a comprehensive time tracking solution with project management, team collaboration, billing & invoicing. Free, open-source, and enterprise-ready.{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}time tracking, project management, team collaboration, billing software, invoice management, enterprise time tracker, open source time tracking{% endblock %}">
<meta name="author" content="{{ g.branding.app_name if g.branding else 'TimeTrack' }}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{ request.url }}">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="{% block og_title %}{{ title }} - {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% endblock %}">
<meta property="og:description" content="{% block og_description %}Transform your productivity with intelligent time tracking, project management, and team collaboration tools. Enterprise-grade, open-source solution.{% endblock %}">
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
<meta property="og:url" content="{{ request.url }}">
<meta property="og:site_name" content="{{ g.branding.app_name if g.branding else 'TimeTrack' }}">
{% if g.branding and g.branding.logo_filename %}
<meta property="og:image" content="{{ url_for('static', filename='uploads/branding/' + g.branding.logo_filename, _external=True) }}">
{% endif %}
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{% block twitter_title %}{{ title }} - {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% endblock %}">
<meta name="twitter:description" content="{% block twitter_description %}Transform your productivity with intelligent time tracking, project management, and team collaboration tools.{% endblock %}">
{% if g.branding and g.branding.logo_filename %}
<meta name="twitter:image" content="{{ url_for('static', filename='uploads/branding/' + g.branding.logo_filename, _external=True) }}">
{% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/hover-standards.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/hover-standards.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile-optimized.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile-forms.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile-bottom-nav.css') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons@latest/iconfont/tabler-icons.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons@latest/iconfont/tabler-icons.min.css">
<!-- PWA Support -->
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<meta name="theme-color" content="#667eea">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/icon-192x192.png') }}">
{% if g.user %} {% if g.user %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/time-tracking.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/time-tracking.css') }}">
{% endif %} {% endif %}
@@ -46,9 +82,25 @@
color: inherit; color: inherit;
text-decoration: none; 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;
}
}
</style> </style>
</head> </head>
<body{% if g.user %} class="has-user"{% endif %}> <body{% if g.user %} class="has-user has-bottom-nav"{% endif %}>
{% if g.user and g.user.preferences %}
<!-- User preferences for JavaScript -->
<div id="user-preferences" style="display: none;"
data-date-format="{{ g.user.preferences.date_format }}"
data-time-format-24h="{{ g.user.preferences.time_format_24h|lower }}">
</div>
{% endif %}
<!-- Mobile header --> <!-- Mobile header -->
{% if g.user %} {% if g.user %}
<header class="mobile-header"> <header class="mobile-header">
@@ -69,6 +121,7 @@
<span></span> <span></span>
</button> </button>
</header> </header>
<div class="mobile-nav-overlay" id="mobile-nav-overlay"></div>
{% endif %} {% endif %}
<!-- Sidebar navigation --> <!-- Sidebar navigation -->
@@ -246,9 +299,43 @@
</div> </div>
</footer> </footer>
<!-- Mobile Bottom Navigation -->
{% if g.user %}
<nav class="mobile-bottom-nav">
<a href="{{ url_for('home') }}" class="bottom-nav-item {% if request.endpoint == 'home' %}active{% endif %}">
<i class="ti ti-home"></i>
<span>Home</span>
</a>
<a href="{{ url_for('projects.admin_projects') }}" class="bottom-nav-item {% if 'project' in request.endpoint %}active{% endif %}">
<i class="ti ti-clipboard-list"></i>
<span>Projects</span>
</a>
<a href="{{ url_for('time_tracking') }}" class="bottom-nav-item nav-fab {% if 'time' in request.endpoint %}active{% endif %}">
<div class="fab-button">
<i class="ti ti-clock"></i>
</div>
<span>Time</span>
</a>
<a href="{{ url_for('notes.notes_list') }}" class="bottom-nav-item {% if 'note' in request.endpoint %}active{% endif %}">
<i class="ti ti-notes"></i>
<span>Notes</span>
</a>
<a href="{{ url_for('profile') }}" class="bottom-nav-item {% if request.endpoint == 'profile' %}active{% endif %}">
<i class="ti ti-user"></i>
<span>Profile</span>
</a>
</nav>
{% endif %}
<script src="{{ url_for('static', filename='js/script.js') }}"></script> <script src="{{ url_for('static', filename='js/script.js') }}"></script>
<script src="{{ url_for('static', filename='js/sidebar.js') }}"></script> <script src="{{ url_for('static', filename='js/sidebar.js') }}"></script>
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script> <script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
<script src="{{ url_for('static', filename='js/mobile-tables.js') }}"></script>
<script src="{{ url_for('static', filename='js/date-formatter.js') }}"></script>
<script src="{{ url_for('static', filename='js/date-picker-enhancer.js') }}"></script>
<script src="{{ url_for('static', filename='js/mobile-gestures.js') }}"></script>
<script src="{{ url_for('static', filename='js/mobile-pull-refresh.js') }}"></script>
<script src="{{ url_for('static', filename='js/mobile-performance.js') }}"></script>
{% if g.user %} {% if g.user %}
<script src="{{ url_for('static', filename='js/user-dropdown.js') }}"></script> <script src="{{ url_for('static', filename='js/user-dropdown.js') }}"></script>
<script> <script>

View File

@@ -35,6 +35,15 @@
Updated {{ note.updated_at|format_date }} Updated {{ note.updated_at|format_date }}
</span> </span>
{% endif %} {% endif %}
{% if note.folder %}
<span class="meta-divider"></span>
<span class="folder">
<span class="icon"><i class="ti ti-folder"></i></span>
<a href="{{ url_for('notes.notes_list', folder=note.folder) }}" class="folder-link">
{{ note.folder }}
</a>
</span>
{% endif %}
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
@@ -89,23 +98,9 @@
</div> </div>
<!-- Note Metadata Card --> <!-- Note Metadata Card -->
{% if note.project or note.task or note.tags or note.folder %} {% if note.project or note.task or note.tags %}
<div class="metadata-card"> <div class="metadata-card">
<div class="metadata-grid"> <div class="metadata-grid">
{% if note.folder %}
<div class="metadata-item">
<span class="metadata-label">
<span class="icon"><i class="ti ti-folder"></i></span>
Folder
</span>
<span class="metadata-value">
<a href="{{ url_for('notes.notes_list', folder=note.folder) }}" class="metadata-link">
{{ note.folder }}
</a>
</span>
</div>
{% endif %}
{% if note.project %} {% if note.project %}
<div class="metadata-item"> <div class="metadata-item">
<span class="metadata-label"> <span class="metadata-label">
@@ -401,6 +396,18 @@
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
} }
.folder-link {
color: white;
text-decoration: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
transition: border-color 0.2s ease;
}
.folder-link:hover {
color: white;
border-bottom-color: rgba(255, 255, 255, 0.8);
}
.pin-badge { .pin-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -158,7 +158,7 @@
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">Member Since</span> <span class="info-label">Member Since</span>
<span class="info-value">{{ user.created_at.strftime('%B %d, %Y') }}</span> <span class="info-value">{{ user.created_at|format_date }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">Account Type</span> <span class="info-label">Account Type</span>

View File

@@ -8,7 +8,7 @@
{% if active_entry %} {% if active_entry %}
<div class="active-timer"> <div class="active-timer">
<h2>Currently Working</h2> <h2>Currently Working</h2>
<p>Started at: {{ active_entry.arrival_time.strftime('%Y-%m-%d %H:%M:%S') }}</p> <p>Started at: {{ active_entry.arrival_time|format_datetime }}</p>
<div id="timer" data-start="{{ active_entry.arrival_time.timestamp() }}">00:00:00</div> <div id="timer" data-start="{{ active_entry.arrival_time.timestamp() }}">00:00:00</div>
<button id="leave-btn" class="leave-btn" data-id="{{ active_entry.id }}">Leave</button> <button id="leave-btn" class="leave-btn" data-id="{{ active_entry.id }}">Leave</button>
</div> </div>
@@ -35,11 +35,11 @@
</thead> </thead>
<tbody id="time-history-body"> <tbody id="time-history-body">
{% for entry in history %} {% for entry in history %}
<tr data-entry-id="{{ entry.id }}"> <tr data-entry-id="{{ entry.id }}" data-iso-date="{{ entry.arrival_time.strftime('%Y-%m-%d') }}">
<td>{{ entry.arrival_time.strftime('%Y-%m-%d') }}</td> <td>{{ entry.arrival_time|format_date }}</td>
<td>{{ entry.arrival_time.strftime('%H:%M:%S') }}</td> <td>{{ entry.arrival_time|format_time }}</td>
<td>{{ entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active' }}</td> <td>{{ entry.departure_time|format_time if entry.departure_time else 'Active' }}</td>
<td>{{ (entry.duration // 3600)|string + 'h ' + ((entry.duration % 3600) // 60)|string + 'm' if entry.duration else 'N/A' }}</td> <td>{{ entry.duration|format_duration if entry.duration else 'N/A' }}</td>
<td> <td>
<button class="edit-entry-btn" data-id="{{ entry.id }}">Edit</button> <button class="edit-entry-btn" data-id="{{ entry.id }}">Edit</button>
<button class="delete-entry-btn" data-id="{{ entry.id }}">Delete</button> <button class="delete-entry-btn" data-id="{{ entry.id }}">Delete</button>
@@ -107,17 +107,18 @@
const arrivalTimeStr = cells[1].textContent; const arrivalTimeStr = cells[1].textContent;
const departureTimeStr = cells[2].textContent !== 'Active' ? cells[2].textContent : ''; const departureTimeStr = cells[2].textContent !== 'Active' ? cells[2].textContent : '';
// Parse date and time // For edit form, we need ISO format (YYYY-MM-DD)
const arrivalDate = new Date(`${dateStr}T${arrivalTimeStr}`); // Parse the displayed date back to ISO format
const entryRow = document.querySelector(`tr[data-entry-id="${entryId}"]`);
const isoDate = entryRow.dataset.isoDate || dateStr;
// Set values in the form // Set values in the form
document.getElementById('edit-entry-id').value = entryId; document.getElementById('edit-entry-id').value = entryId;
document.getElementById('edit-arrival-date').value = dateStr; document.getElementById('edit-arrival-date').value = isoDate;
document.getElementById('edit-arrival-time').value = arrivalTimeStr; document.getElementById('edit-arrival-time').value = arrivalTimeStr;
if (departureTimeStr) { if (departureTimeStr) {
const departureDate = new Date(`${dateStr}T${departureTimeStr}`); document.getElementById('edit-departure-date').value = isoDate;
document.getElementById('edit-departure-date').value = dateStr;
document.getElementById('edit-departure-time').value = departureTimeStr; document.getElementById('edit-departure-time').value = departureTimeStr;
} else { } else {
document.getElementById('edit-departure-date').value = ''; document.getElementById('edit-departure-date').value = '';