Merge pull request #23 from DRYTRIX/develop

feat: add time-entry editing; improve invoices/PDF; harden Docker startup
This commit is contained in:
Dries Peeters
2025-09-03 09:49:58 +02:00
committed by GitHub
26 changed files with 1709 additions and 340 deletions

7
.gitattributes vendored Normal file
View File

@@ -0,0 +1,7 @@
# Enforce LF endings for executable scripts to avoid /usr/bin/env CRLF issues
*.sh text eol=lf
*.py text eol=lf
# Optional: keep everything else automatic
* text=auto

View File

@@ -10,6 +10,8 @@ ENV FLASK_ENV=production
RUN apt-get update && apt-get install -y \
curl \
tzdata \
bash \
dos2unix \
# Network tools for debugging
iproute2 \
net-tools \
@@ -51,8 +53,13 @@ RUN mkdir -p /app/app/static/uploads/logos /app/static/uploads/logos && \
# Copy the startup script and ensure it's executable
COPY docker/start-fixed.py /app/start.py
# Make startup scripts executable
RUN chmod +x /app/start.py /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/init-database-enhanced.py /app/docker/verify-database.py /app/docker/test-db.py /app/docker/test-routing.py /app/docker/entrypoint.sh /app/docker/entrypoint_fixed.sh /app/docker/startup_with_migration.py /app/docker/test_db_connection.py /app/docker/debug_startup.sh /app/docker/simple_test.sh
# Fix line endings for the startup and entrypoint scripts
RUN dos2unix /app/start.py /app/docker/entrypoint_fixed.sh /app/docker/entrypoint.sh /app/docker/entrypoint_simple.sh 2>/dev/null || true
# Make startup scripts executable and ensure proper line endings
RUN chmod +x /app/start.py /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/init-database-enhanced.py /app/docker/verify-database.py /app/docker/test-db.py /app/docker/test-routing.py /app/docker/entrypoint.sh /app/docker/entrypoint_fixed.sh /app/docker/entrypoint_simple.sh /app/docker/entrypoint.py /app/docker/startup_with_migration.py /app/docker/test_db_connection.py /app/docker/debug_startup.sh /app/docker/simple_test.sh && \
ls -la /app/docker/entrypoint.py && \
head -5 /app/docker/entrypoint.py
# Create non-root user
RUN useradd -m -u 1000 timetracker && \
@@ -62,6 +69,10 @@ RUN useradd -m -u 1000 timetracker && \
RUN ls -la /app/start.py && \
head -1 /app/start.py
# Verify entrypoint script exists and is accessible
RUN ls -la /app/docker/entrypoint.py && \
head -1 /app/docker/entrypoint.py
USER timetracker
# Expose port
@@ -71,8 +82,8 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8080/_health || exit 1
# Set the entrypoint
# Set the entrypoint back to the fixed shell script
ENTRYPOINT ["/app/docker/entrypoint_fixed.sh"]
# Run the application
# Run the application via python to avoid shebang/CRLF issues
CMD ["python", "/app/start.py"]

View File

@@ -16,7 +16,6 @@ A comprehensive web-based time tracking application built with Flask, featuring
<div align="center">
<img src="assets/screenshots/Reports.png" alt="Reports" width="300" style="margin: 10px;">
<img src="assets/screenshots/VisualAnalytics.png" alt="Visual Analytics" width="300" style="margin: 10px;">
<img src="assets/screenshots/Task_Management.png" alt="Task Management" width="300" style="margin: 10px;">
<img src="assets/screenshots/Admin.png" alt="Admin Panel" width="300" style="margin: 10px;">
</div>
@@ -433,7 +432,7 @@ See `docs/CONTRIBUTING.md` for detailed guidelines.
## 📄 License
This project is licensed under the MIT License - see the `docs/LICENSE` file for details.
This project is licensed under the GNU General Public License v3.0 — see `LICENSE` for details.
## 🆘 Support

View File

@@ -13,6 +13,8 @@ class Invoice(db.Model):
client_name = db.Column(db.String(200), nullable=False)
client_email = db.Column(db.String(200), nullable=True)
client_address = db.Column(db.Text, nullable=True)
# Link to clients table (enforced by DB schema)
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True)
# Invoice details
issue_date = db.Column(db.Date, nullable=False, default=datetime.utcnow().date)
@@ -36,15 +38,17 @@ class Invoice(db.Model):
# Relationships
project = db.relationship('Project', backref='invoices')
client = db.relationship('Client', backref='invoices')
creator = db.relationship('User', backref='created_invoices')
items = db.relationship('InvoiceItem', backref='invoice', lazy='dynamic', cascade='all, delete-orphan')
def __init__(self, invoice_number, project_id, client_name, due_date, created_by, **kwargs):
def __init__(self, invoice_number, project_id, client_name, due_date, created_by, client_id, **kwargs):
self.invoice_number = invoice_number
self.project_id = project_id
self.client_name = client_name
self.due_date = due_date
self.created_by = created_by
self.client_id = client_id
# Set optional fields
self.client_email = kwargs.get('client_email')
@@ -89,6 +93,7 @@ class Invoice(db.Model):
'client_name': self.client_name,
'client_email': self.client_email,
'client_address': self.client_address,
'client_id': self.client_id,
'issue_date': self.issue_date.isoformat() if self.issue_date else None,
'due_date': self.due_date.isoformat() if self.due_date else None,
'status': self.status,

View File

@@ -3,6 +3,9 @@ from flask_login import login_required, current_user
from app import db, socketio
from app.models import User, Project, TimeEntry, Settings, Task
from datetime import datetime, timedelta
from app.utils.db import safe_commit
from app.utils.timezone import parse_local_datetime, utc_to_local
from app.models.time_entry import local_now
import json
api_bp = Blueprint('api', __name__)
@@ -164,8 +167,15 @@ def get_entries():
error_out=False
)
# Ensure frontend receives project_name like other endpoints
entries_payload = []
for entry in entries.items:
e = entry.to_dict()
e['project_name'] = e.get('project') or (entry.project.name if entry.project else None)
entries_payload.append(e)
return jsonify({
'entries': [entry.to_dict() for entry in entries.items],
'entries': entries_payload,
'total': entries.total,
'pages': entries.pages,
'current_page': entries.page,
@@ -182,6 +192,40 @@ def get_projects():
'projects': [project.to_dict() for project in projects]
})
@api_bp.route('/api/projects/<int:project_id>/tasks')
@login_required
def get_project_tasks(project_id):
"""Get tasks for a specific project"""
# Check if project exists and is active
project = Project.query.filter_by(id=project_id, status='active').first()
if not project:
return jsonify({'error': 'Project not found or inactive'}), 404
# Get tasks for the project
tasks = Task.query.filter_by(project_id=project_id).order_by(Task.name).all()
return jsonify({
'success': True,
'tasks': [{
'id': task.id,
'name': task.name,
'description': task.description,
'status': task.status,
'priority': task.priority
} for task in tasks]
})
# Fetch a single time entry (details for edit modal)
@api_bp.route('/api/entry/<int:entry_id>', methods=['GET'])
@login_required
def get_entry(entry_id):
entry = TimeEntry.query.get_or_404(entry_id)
if entry.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Access denied'}), 403
payload = entry.to_dict()
payload['project_name'] = entry.project.name if entry.project else None
return jsonify(payload)
@api_bp.route('/api/users')
@login_required
def get_users():
@@ -235,25 +279,77 @@ def update_entry(entry_id):
if entry.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Access denied'}), 403
data = request.get_json()
# Update fields
data = request.get_json() or {}
# Optional: project change (admin only)
new_project_id = data.get('project_id')
if new_project_id is not None and current_user.is_admin:
if new_project_id != entry.project_id:
project = Project.query.filter_by(id=new_project_id, status='active').first()
if not project:
return jsonify({'error': 'Invalid project'}), 400
entry.project_id = new_project_id
# Optional: start/end time updates (admin only for safety)
# Accept HTML datetime-local format: YYYY-MM-DDTHH:MM
def parse_dt_local(dt_str):
if not dt_str:
return None
try:
if 'T' in dt_str:
date_part, time_part = dt_str.split('T', 1)
else:
date_part, time_part = dt_str.split(' ', 1)
# Parse as UTC-aware then convert to local naive to match model storage
parsed_utc = parse_local_datetime(date_part, time_part)
parsed_local_aware = utc_to_local(parsed_utc)
return parsed_local_aware.replace(tzinfo=None)
except Exception:
return None
if current_user.is_admin:
start_time_str = data.get('start_time')
end_time_str = data.get('end_time')
if start_time_str:
parsed_start = parse_dt_local(start_time_str)
if not parsed_start:
return jsonify({'error': 'Invalid start time format'}), 400
entry.start_time = parsed_start
if end_time_str is not None:
if end_time_str == '' or end_time_str is False:
entry.end_time = None
entry.duration_seconds = None
else:
parsed_end = parse_dt_local(end_time_str)
if not parsed_end:
return jsonify({'error': 'Invalid end time format'}), 400
if parsed_end <= (entry.start_time or parsed_end):
return jsonify({'error': 'End time must be after start time'}), 400
entry.end_time = parsed_end
# Recalculate duration
entry.calculate_duration()
# Notes, tags, billable (both admin and owner can change)
if 'notes' in data:
entry.notes = data['notes'].strip() if data['notes'] else None
if 'tags' in data:
entry.tags = data['tags'].strip() if data['tags'] else None
if 'billable' in data:
entry.billable = bool(data['billable'])
entry.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'entry': entry.to_dict()
})
# Prefer local time for updated_at per project preference
entry.updated_at = local_now()
if not safe_commit('api_update_entry', {'entry_id': entry_id}):
return jsonify({'error': 'Database error while updating entry'}), 500
payload = entry.to_dict()
payload['project_name'] = entry.project.name if entry.project else None
return jsonify({'success': True, 'entry': payload})
@api_bp.route('/api/entry/<int:entry_id>', methods=['DELETE'])
@login_required

View File

@@ -85,6 +85,7 @@ def create_invoice():
client_name=client_name,
due_date=due_date,
created_by=current_user.id,
client_id=project.client_id,
client_email=client_email,
client_address=client_address,
tax_rate=tax_rate,
@@ -393,7 +394,7 @@ def export_invoice_pdf(invoice_id):
# Check access permissions
if not current_user.is_admin and invoice.created_by != current_user.id:
flash('You do not have permission to export this invoice', 'error')
return redirect(url_for('invoices.list_invoices'))
return redirect(request.referrer or url_for('invoices.list_invoices'))
try:
from app.utils.pdf_generator import InvoicePDFGenerator
@@ -414,7 +415,7 @@ def export_invoice_pdf(invoice_id):
except ImportError:
flash('PDF generation is not available. Please install WeasyPrint.', 'error')
return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id))
return redirect(request.referrer or url_for('invoices.view_invoice', invoice_id=invoice.id))
except Exception as e:
# Try fallback PDF generator
try:
@@ -438,7 +439,7 @@ def export_invoice_pdf(invoice_id):
except Exception as fallback_error:
flash(f'PDF generation failed: {str(e)}. Fallback also failed: {str(fallback_error)}', 'error')
return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id))
return redirect(request.referrer or url_for('invoices.view_invoice', invoice_id=invoice.id))
@invoices_bp.route('/invoices/<int:invoice_id>/duplicate')
@login_required
@@ -463,6 +464,7 @@ def duplicate_invoice(invoice_id):
client_address=original_invoice.client_address,
due_date=original_invoice.due_date + timedelta(days=30), # 30 days from original due date
created_by=current_user.id,
client_id=original_invoice.client_id,
tax_rate=original_invoice.tax_rate,
notes=original_invoice.notes,
terms=original_invoice.terms

View File

@@ -2,7 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
from flask_login import login_required, current_user
from app import db, socketio
from app.models import User, Project, TimeEntry, Task, Settings
from app.utils.timezone import parse_local_datetime
from app.utils.timezone import parse_local_datetime, utc_to_local
from datetime import datetime
import json
from app.utils.db import safe_commit
@@ -234,13 +234,106 @@ def edit_timer(timer_id):
timer.tags = request.form.get('tags', '').strip()
timer.billable = request.form.get('billable') == 'on'
# Admin users can edit additional fields
if current_user.is_admin:
# Update project if changed
new_project_id = request.form.get('project_id', type=int)
if new_project_id and new_project_id != timer.project_id:
new_project = Project.query.filter_by(id=new_project_id, status='active').first()
if new_project:
timer.project_id = new_project_id
else:
flash('Invalid project selected', 'error')
return render_template('timer/edit_timer.html', timer=timer,
projects=Project.query.filter_by(status='active').order_by(Project.name).all(),
tasks=[] if not new_project_id else Task.query.filter_by(project_id=new_project_id).order_by(Task.name).all())
# Update task if changed
new_task_id = request.form.get('task_id', type=int)
if new_task_id != timer.task_id:
if new_task_id:
new_task = Task.query.filter_by(id=new_task_id, project_id=timer.project_id).first()
if new_task:
timer.task_id = new_task_id
else:
flash('Invalid task selected for the chosen project', 'error')
return render_template('timer/edit_timer.html', timer=timer,
projects=Project.query.filter_by(status='active').order_by(Project.name).all(),
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all())
else:
timer.task_id = None
# Update start and end times if provided
start_date = request.form.get('start_date')
start_time = request.form.get('start_time')
end_date = request.form.get('end_date')
end_time = request.form.get('end_time')
if start_date and start_time:
try:
# Convert parsed UTC-aware to local naive to match model storage
parsed_start_utc = parse_local_datetime(start_date, start_time)
new_start_time = utc_to_local(parsed_start_utc).replace(tzinfo=None)
# Validate that start time is not in the future
from app.models.time_entry import local_now
current_time = local_now()
if new_start_time > current_time:
flash('Start time cannot be in the future', 'error')
return render_template('timer/edit_timer.html', timer=timer,
projects=Project.query.filter_by(status='active').order_by(Project.name).all(),
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all())
timer.start_time = new_start_time
except ValueError:
flash('Invalid start date/time format', 'error')
return render_template('timer/edit_timer.html', timer=timer,
projects=Project.query.filter_by(status='active').order_by(Project.name).all(),
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all())
if end_date and end_time:
try:
# Convert parsed UTC-aware to local naive to match model storage
parsed_end_utc = parse_local_datetime(end_date, end_time)
new_end_time = utc_to_local(parsed_end_utc).replace(tzinfo=None)
# Validate that end time is after start time
if new_end_time <= timer.start_time:
flash('End time must be after start time', 'error')
return render_template('timer/edit_timer.html', timer=timer,
projects=Project.query.filter_by(status='active').order_by(Project.name).all(),
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all())
timer.end_time = new_end_time
# Recalculate duration
timer.calculate_duration()
except ValueError:
flash('Invalid end date/time format', 'error')
return render_template('timer/edit_timer.html', timer=timer,
projects=Project.query.filter_by(status='active').order_by(Project.name).all(),
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all())
# Update source if provided
new_source = request.form.get('source')
if new_source in ['manual', 'auto']:
timer.source = new_source
if not safe_commit('edit_timer', {'timer_id': timer.id}):
flash('Could not update timer due to a database error. Please check server logs.', 'error')
return redirect(url_for('main.dashboard'))
flash('Timer updated successfully', 'success')
return redirect(url_for('main.dashboard'))
return render_template('timer/edit_timer.html', timer=timer)
# Get projects and tasks for admin users
projects = []
tasks = []
if current_user.is_admin:
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
if timer.project_id:
tasks = Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all()
return render_template('timer/edit_timer.html', timer=timer, projects=projects, tasks=tasks)
@timer_bp.route('/timer/delete/<int:timer_id>', methods=['POST'])
@login_required

View File

@@ -177,17 +177,19 @@ class MobileForms {
handleFormSubmit(event, form) {
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
// If form is invalid, let browser show native messages and do NOT lock the button
if (typeof form.checkValidity === 'function' && !form.checkValidity()) {
return;
}
// Store original text if not already stored
if (!submitBtn.getAttribute('data-original-text')) {
submitBtn.setAttribute('data-original-text', submitBtn.innerHTML);
}
// Show loading state
// Show loading state and allow native submit
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Processing...';
submitBtn.disabled = true;
// Allow form to submit normally
// The form will be submitted and page will reload, so we don't need to re-enable
}
}

View File

@@ -33,8 +33,8 @@ class InvoicePDFGenerator:
except Exception:
base_url = None
html_doc = HTML(string=html_content, base_url=base_url)
css_doc = CSS(string=css_content)
pdf_bytes = html_doc.write_pdf(stylesheets=[css_doc])
css_doc = CSS(string=css_content, font_config=font_config)
pdf_bytes = html_doc.write_pdf(stylesheets=[css_doc], font_config=font_config)
return pdf_bytes
@@ -92,12 +92,19 @@ class InvoicePDFGenerator:
.small {{ color: var(--muted); font-size: 10pt; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 4px; }}
thead {{ display: table-header-group; }}
tfoot {{ display: table-footer-group; }}
thead th {{ background: var(--bg-alt); color: var(--muted); font-weight: 700; border: 1px solid var(--border); padding: 10px; font-size: 10.5pt; text-align: left; }}
tbody td {{ border: 1px solid var(--border); padding: 10px; font-size: 10.5pt; }}
tfoot td {{ border: 1px solid var(--border); padding: 10px; font-weight: 700; }}
.num {{ text-align: right; }}
.desc {{ width: 50%; }}
/* Pagination controls */
tr, td, th {{ break-inside: avoid; page-break-inside: avoid; }}
.card, .invoice-header, .two-col {{ break-inside: avoid; page-break-inside: avoid; }}
h4 {{ break-after: avoid; }}
.totals {{ margin-top: 6px; }}
.note {{ margin-top: 10px; }}
.footer {{ border-top: 1px solid var(--border); margin-top: 18px; padding-top: 10px; color: var(--muted); font-size: 10pt; }}
@@ -232,8 +239,8 @@ class InvoicePDFGenerator:
{self._get_time_entry_info_html(item)}
</td>
<td class="num">{item.quantity:.2f}</td>
<td class="num">{item.unit_price:.2f} {self.settings.currency}</td>
<td class="num">{item.total_amount:.2f} {self.settings.currency}</td>
<td class="num">{self._format_currency(item.unit_price)}</td>
<td class="num">{self._format_currency(item.total_amount)}</td>
</tr>
"""
rows.append(row)
@@ -254,7 +261,7 @@ class InvoicePDFGenerator:
rows.append(f"""
<tr>
<td colspan="3" class="num">Subtotal:</td>
<td class="num">{self.invoice.subtotal:.2f} {self.settings.currency}</td>
<td class="num">{self._format_currency(self.invoice.subtotal)}</td>
</tr>
""")
@@ -263,7 +270,7 @@ class InvoicePDFGenerator:
rows.append(f"""
<tr>
<td colspan="3" class="num">Tax ({self.invoice.tax_rate:.2f}%):</td>
<td class="num">{self.invoice.tax_amount:.2f} {self.settings.currency}</td>
<td class="num">{self._format_currency(self.invoice.tax_amount)}</td>
</tr>
""")
@@ -271,7 +278,7 @@ class InvoicePDFGenerator:
rows.append(f"""
<tr>
<td colspan="3" class="num">Total Amount:</td>
<td class="num">{self.invoice.total_amount:.2f} {self.settings.currency}</td>
<td class="num">{self._format_currency(self.invoice.total_amount)}</td>
</tr>
""")
@@ -301,6 +308,13 @@ class InvoicePDFGenerator:
return f'<div class="additional-info">{"".join(html_parts)}</div>'
return ''
def _format_currency(self, value):
"""Format numeric currency with thousands separators and 2 decimals."""
try:
return f"{float(value):,.2f} {self.settings.currency}"
except Exception:
return f"{value} {self.settings.currency}"
def _get_payment_info_html(self):
"""Generate HTML for payment information"""
if self.settings.company_bank_info:

View File

@@ -81,8 +81,8 @@ class InvoicePDFGeneratorFallback:
# Build the story (content)
story = self._build_story()
# Build the PDF
doc.build(story)
# Build the PDF with page numbers
doc.build(story, onFirstPage=self._add_page_number, onLaterPages=self._add_page_number)
# Read the generated PDF
with open(tmp_path, 'rb') as f:
@@ -216,21 +216,21 @@ class InvoicePDFGeneratorFallback:
row = [
item.description,
f"{item.quantity:.2f}",
f"{item.unit_price:.2f} {self.settings.currency}",
f"{item.total_amount:.2f} {self.settings.currency}"
self._format_currency(item.unit_price),
self._format_currency(item.total_amount)
]
data.append(row)
# Add totals
data.append(['', '', 'Subtotal:', f"{self.invoice.subtotal:.2f} {self.settings.currency}"])
data.append(['', '', 'Subtotal:', self._format_currency(self.invoice.subtotal)])
if self.invoice.tax_rate > 0:
data.append(['', '', f'Tax ({self.invoice.tax_rate:.2f}%):', f"{self.invoice.tax_amount:.2f} {self.settings.currency}"])
data.append(['', '', f'Tax ({self.invoice.tax_rate:.2f}%):', self._format_currency(self.invoice.tax_amount)])
data.append(['', '', 'Total Amount:', f"{self.invoice.total_amount:.2f} {self.settings.currency}"])
data.append(['', '', 'Total Amount:', self._format_currency(self.invoice.total_amount)])
# Create table
table = Table(data, colWidths=[9*cm, 3*cm, 3*cm, 3*cm])
table = Table(data, colWidths=[9*cm, 3*cm, 3*cm, 3*cm], repeatRows=1)
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f8fafc')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#475569')),
@@ -247,6 +247,26 @@ class InvoicePDFGeneratorFallback:
story.append(table)
return story
def _format_currency(self, value):
"""Format numeric currency with thousands separators and 2 decimals."""
try:
return f"{float(value):,.2f} {self.settings.currency}"
except Exception:
return f"{value} {self.settings.currency}"
def _add_page_number(self, canv, doc):
"""Add page number at the bottom-right of each page."""
page_num = canv.getPageNumber()
text = f"Page {page_num}"
canv.setFont('Helvetica', 9)
try:
canv.setFillColor(colors.HexColor('#666666'))
except Exception:
pass
x = doc.leftMargin + doc.width
y = doc.bottomMargin - 0.5*cm
canv.drawRightString(x, y, text)
def _build_additional_info(self):
"""Build additional information section"""

View File

@@ -1,53 +0,0 @@
This is a placeholder for the enhanced task management screenshot.
The screenshot should show:
1. **Modern Header Section**
- Card-based header with task icon and description
- Clean, professional appearance
2. **Quick Stats Section**
- Cards showing task counts by status (To Do, In Progress, Review, Done)
- Color-coded status indicators
- Modern card design with hover effects
3. **Enhanced Filter Section**
- Search input with icon
- Dropdown filters for status, priority, project, assignee
- Apply Filters and Clear buttons
- Professional form layout
4. **Task Cards Grid**
- Responsive grid layout of task cards
- Each card showing:
- Task name and description
- Priority badge with color coding
- Status badge
- Project information
- Due date with overdue indicators
- Assigned user
- Estimated vs actual hours
- Quick action buttons
- Hover effects and smooth transitions
- Priority-based left border colors
5. **Mobile-Responsive Design**
- Cards that stack properly on mobile
- Touch-friendly buttons and interactions
- Optimized spacing for all screen sizes
6. **Modern UI Elements**
- Bootstrap 5 styling
- Custom CSS variables for consistent theming
- Smooth animations and transitions
- Professional color scheme
- Icon integration (Font Awesome)
The overall appearance should be:
- Clean and modern
- Professional and business-like
- Easy to read and navigate
- Visually appealing with good contrast
- Consistent with the application's design system
This screenshot will replace the existing Tasks.png to showcase the new enhanced interface.

181
docker/entrypoint.py Normal file
View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
Python entrypoint script for TimeTracker Docker container
This avoids shell script line ending issues and provides better error handling
"""
import os
import sys
import time
import subprocess
import traceback
import psycopg2
from urllib.parse import urlparse
def log(message):
"""Log message with timestamp"""
from datetime import datetime
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"[{timestamp}] {message}")
def wait_for_database():
"""Wait for database to be ready"""
log("Waiting for database to be available...")
db_url = os.getenv('DATABASE_URL')
if not db_url:
log("✗ DATABASE_URL environment variable not set")
return False
log(f"Database URL: {db_url}")
max_attempts = 30
retry_delay = 2
for attempt in range(1, max_attempts + 1):
log(f"Attempt {attempt}/{max_attempts} to connect to database...")
try:
if db_url.startswith('postgresql'):
# Parse connection string
if db_url.startswith('postgresql+psycopg2://'):
db_url = db_url.replace('postgresql+psycopg2://', '')
if '@' in db_url:
auth_part, rest = db_url.split('@', 1)
user, password = auth_part.split(':', 1)
if ':' in rest:
host_port, database = rest.rsplit('/', 1)
if ':' in host_port:
host, port = host_port.split(':', 1)
else:
host, port = host_port, '5432'
else:
host, port, database = rest, '5432', 'timetracker'
else:
host, port, database, user, password = 'db', '5432', 'timetracker', 'timetracker', 'timetracker'
conn = psycopg2.connect(
host=host,
port=port,
database=database,
user=user,
password=password,
connect_timeout=5
)
conn.close()
log("✓ PostgreSQL database is available")
return True
elif db_url.startswith('sqlite://'):
db_file = db_url.replace('sqlite://', '')
if os.path.exists(db_file) or os.access(os.path.dirname(db_file), os.W_OK):
log("✓ SQLite database is available")
return True
else:
log("SQLite file not accessible")
else:
log(f"Unknown database URL format: {db_url}")
except Exception as e:
log(f"Database connection failed: {e}")
if attempt < max_attempts:
log(f"Waiting {retry_delay} seconds before next attempt...")
time.sleep(retry_delay)
log("✗ Database is not available after maximum retries")
return False
def run_migrations():
"""Run database migrations"""
log("Checking migrations...")
try:
# Check if migrations directory exists
if os.path.exists("/app/migrations"):
log("Migrations directory exists, checking status...")
# Try to apply any pending migrations
result = subprocess.run(['flask', 'db', 'upgrade'],
capture_output=True, text=True, timeout=60)
if result.returncode == 0:
log("✓ Migrations applied successfully")
return True
else:
log(f"⚠ Migration application failed: {result.stderr}")
return False
else:
log("No migrations directory found, initializing...")
# Initialize migrations
result = subprocess.run(['flask', 'db', 'init'],
capture_output=True, text=True, timeout=60)
if result.returncode == 0:
log("✓ Migrations initialized")
# Create initial migration
result = subprocess.run(['flask', 'db', 'migrate', '-m', 'Initial schema'],
capture_output=True, text=True, timeout=60)
if result.returncode == 0:
log("✓ Initial migration created")
# Apply migration
result = subprocess.run(['flask', 'db', 'upgrade'],
capture_output=True, text=True, timeout=60)
if result.returncode == 0:
log("✓ Initial migration applied")
return True
else:
log(f"⚠ Initial migration application failed: {result.stderr}")
return False
else:
log(f"⚠ Initial migration creation failed: {result.stderr}")
return False
else:
log(f"⚠ Migration initialization failed: {result.stderr}")
return False
except subprocess.TimeoutExpired:
log("⚠ Migration operation timed out")
return False
except Exception as e:
log(f"⚠ Migration error: {e}")
return False
def main():
"""Main entrypoint function"""
log("=== TimeTracker Docker Entrypoint ===")
# Set environment variables
os.environ.setdefault('FLASK_APP', '/app/app.py')
# Wait for database
if not wait_for_database():
log("✗ Failed to connect to database")
sys.exit(1)
# Run migrations
if not run_migrations():
log("⚠ Migration issues detected, continuing anyway")
log("=== Startup Complete ===")
log("Starting TimeTracker application...")
# Execute the command passed to the container
if len(sys.argv) > 1:
try:
os.execv(sys.argv[1], sys.argv[1:])
except Exception as e:
log(f"✗ Failed to execute command: {e}")
sys.exit(1)
else:
# Default command
try:
os.execv('/usr/bin/python', ['python', '/app/start.py'])
except Exception as e:
log(f"✗ Failed to execute default command: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

136
docker/entrypoint_simple.sh Normal file
View File

@@ -0,0 +1,136 @@
#!/bin/bash
set -e
echo "=== TimeTracker Docker Container Starting ==="
echo "Timestamp: $(date)"
echo "Container ID: $(hostname)"
echo "Current directory: $(pwd)"
echo "User: $(whoami)"
echo "Python version: $(python --version 2>/dev/null || echo 'Python not available')"
echo
# Function to log messages with timestamp
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
# Function to wait for database
wait_for_database() {
local db_url="$1"
local max_retries=30
local retry_delay=2
log "Waiting for database to be available..."
log "Database URL: $db_url"
for attempt in $(seq 1 $max_retries); do
log "Attempt $attempt/$max_retries to connect to database..."
if [[ "$db_url" == postgresql* ]]; then
if python -c "
import psycopg2
import sys
try:
conn_str = '$db_url'.replace('+psycopg2://', '://')
conn = psycopg2.connect(conn_str)
conn.close()
print('Connection successful')
sys.exit(0)
except Exception as e:
print(f'Connection failed: {e}')
sys.exit(1)
" 2>/dev/null; then
log "✓ PostgreSQL database is available"
return 0
fi
elif [[ "$db_url" == sqlite://* ]]; then
local db_file="${db_url#sqlite://}"
if [[ -f "$db_file" ]] || [[ -w "$(dirname "$db_file")" ]]; then
log "✓ SQLite database is available"
return 0
fi
fi
log "Database not ready (attempt $attempt/$max_retries)"
if [[ $attempt -lt $max_retries ]]; then
log "Waiting $retry_delay seconds before next attempt..."
sleep $retry_delay
fi
done
log "✗ Database is not available after maximum retries"
return 1
}
# Main execution
main() {
log "=== TimeTracker Docker Entrypoint ==="
# Set environment variables
export FLASK_APP=${FLASK_APP:-/app/app.py}
# Get database URL from environment
local db_url="${DATABASE_URL}"
if [[ -z "$db_url" ]]; then
log "✗ DATABASE_URL environment variable not set"
log "Available environment variables:"
env | grep -E "(DATABASE|FLASK|PYTHON)" | sort
exit 1
fi
log "Database URL: $db_url"
# Wait for database to be available
if ! wait_for_database "$db_url"; then
log "✗ Failed to connect to database"
exit 1
fi
# Check if migrations directory exists
if [[ -d "/app/migrations" ]]; then
log "Migrations directory exists, checking status..."
# Try to apply any pending migrations
if command -v flask >/dev/null 2>&1; then
log "Applying pending migrations..."
if flask db upgrade; then
log "✓ Migrations applied successfully"
else
log "⚠ Migration application failed, continuing anyway"
fi
else
log "⚠ Flask CLI not available, skipping migrations"
fi
else
log "No migrations directory found, initializing..."
if command -v flask >/dev/null 2>&1; then
if flask db init; then
log "✓ Migrations initialized"
if flask db migrate -m "Initial schema"; then
log "✓ Initial migration created"
if flask db upgrade; then
log "✓ Initial migration applied"
else
log "⚠ Initial migration application failed"
fi
else
log "⚠ Initial migration creation failed"
fi
else
log "⚠ Migration initialization failed"
fi
else
log "⚠ Flask CLI not available, skipping migration setup"
fi
fi
log "=== Startup Complete ==="
log "Starting TimeTracker application..."
# Execute the command passed to the container
exec "$@"
}
# Run main function with all arguments
main "$@"

View File

@@ -174,6 +174,7 @@ def get_required_schema():
'columns': [
'id SERIAL PRIMARY KEY',
'invoice_number VARCHAR(50) UNIQUE NOT NULL',
'client_id INTEGER NOT NULL',
'project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE',
'client_name VARCHAR(200) NOT NULL',
'client_email VARCHAR(200)',
@@ -193,6 +194,7 @@ def get_required_schema():
],
'indexes': [
'CREATE INDEX IF NOT EXISTS idx_invoices_project_id ON invoices(project_id)',
'CREATE INDEX IF NOT EXISTS idx_invoices_client_id ON invoices(client_id)',
'CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)',
'CREATE INDEX IF NOT EXISTS idx_invoices_issue_date ON invoices(issue_date)'
]
@@ -338,49 +340,55 @@ def insert_initial_data(engine):
# Get admin username from environment
admin_username = os.getenv('ADMIN_USERNAMES', 'admin').split(',')[0]
# Insert default admin user
# Insert default admin user (idempotent via unique username)
conn.execute(text(f"""
INSERT INTO users (username, role, is_active)
VALUES ('{admin_username}', 'admin', true)
ON CONFLICT (username) DO NOTHING;
SELECT '{admin_username}', 'admin', true
WHERE NOT EXISTS (
SELECT 1 FROM users WHERE username = '{admin_username}'
);
"""))
# Ensure default client exists
# Ensure default client exists (idempotent via unique name)
conn.execute(text("""
INSERT INTO clients (name, status)
VALUES ('Default Client', 'active')
ON CONFLICT (name) DO NOTHING;
SELECT 'Default Client', 'active'
WHERE NOT EXISTS (
SELECT 1 FROM clients WHERE name = 'Default Client'
);
"""))
# Insert default project (link to default client if possible)
# Insert default project linked to default client if not present
conn.execute(text("""
INSERT INTO projects (name, client_id, description, billable, status)
VALUES (
'General',
(SELECT id FROM clients WHERE name = 'Default Client'),
'Default project for general tasks',
true,
'active'
)
ON CONFLICT DO NOTHING;
SELECT 'General', c.id, 'Default project for general tasks', true, 'active'
FROM clients c
WHERE c.name = 'Default Client'
AND NOT EXISTS (
SELECT 1 FROM projects p WHERE p.name = 'General'
);
"""))
# Insert default settings
# Insert default settings only if none exist (singleton semantics)
conn.execute(text("""
INSERT INTO settings (timezone, currency, rounding_minutes, single_active_timer,
allow_self_register, idle_timeout_minutes, backup_retention_days,
backup_time, export_delimiter, allow_analytics,
company_name, company_address, company_email, company_phone,
company_website, company_logo_filename, company_tax_id,
company_bank_info, invoice_prefix, invoice_start_number,
invoice_terms, invoice_notes)
VALUES ('Europe/Rome', 'EUR', 1, true, true, 30, 30, '02:00', ',', true,
'Your Company Name', 'Your Company Address', 'info@yourcompany.com',
'+1 (555) 123-4567', 'www.yourcompany.com', '', '', '', 'INV', 1000,
'Payment is due within 30 days of invoice date.', 'Thank you for your business!')
ON CONFLICT (id) DO NOTHING;
INSERT INTO settings (
timezone, currency, rounding_minutes, single_active_timer,
allow_self_register, idle_timeout_minutes, backup_retention_days,
backup_time, export_delimiter, allow_analytics,
company_name, company_address, company_email, company_phone,
company_website, company_logo_filename, company_tax_id,
company_bank_info, invoice_prefix, invoice_start_number,
invoice_terms, invoice_notes
)
SELECT 'Europe/Rome', 'EUR', 1, true, true, 30, 30, '02:00', ',', true,
'Your Company Name', 'Your Company Address', 'info@yourcompany.com',
'+1 (555) 123-4567', 'www.yourcompany.com', '', '', '', 'INV', 1000,
'Payment is due within 30 days of invoice date.', 'Thank you for your business!'
WHERE NOT EXISTS (
SELECT 1 FROM settings
);
"""))
conn.commit()
print("✓ Initial data inserted successfully")

View File

@@ -82,6 +82,7 @@ def create_tables_sql(engine):
CREATE TABLE IF NOT EXISTS invoices (
id SERIAL PRIMARY KEY,
invoice_number VARCHAR(50) UNIQUE NOT NULL,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
client_name VARCHAR(200) NOT NULL,
client_email VARCHAR(200),
@@ -186,6 +187,7 @@ def create_indexes(engine):
CREATE INDEX IF NOT EXISTS idx_time_entries_user_id ON time_entries(user_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_project_id ON time_entries(project_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries(start_time);
CREATE INDEX IF NOT EXISTS idx_invoices_client_id ON invoices(client_id);
"""
try:
@@ -261,20 +263,42 @@ def insert_initial_data(engine):
admin_username = os.getenv('ADMIN_USERNAMES', 'admin').split(',')[0]
insert_sql = f"""
-- Insert default admin user
-- Insert default admin user idempotently
INSERT INTO users (username, role, is_active)
VALUES ('{admin_username}', 'admin', true)
ON CONFLICT (username) DO NOTHING;
SELECT '{admin_username}', 'admin', true
WHERE NOT EXISTS (
SELECT 1 FROM users WHERE username = '{admin_username}'
);
-- Insert default project
-- Ensure default client exists
INSERT INTO clients (name, status)
SELECT 'Default Client', 'active'
WHERE NOT EXISTS (
SELECT 1 FROM clients WHERE name = 'Default Client'
);
-- Insert default project idempotently and link to default client
INSERT INTO projects (name, client, description, billable, status)
VALUES ('General', 'Default Client', 'Default project for general tasks', true, 'active')
ON CONFLICT DO NOTHING;
SELECT 'General', 'Default Client', 'Default project for general tasks', true, 'active'
WHERE NOT EXISTS (
SELECT 1 FROM projects WHERE name = 'General'
);
-- Insert default settings
INSERT INTO settings (timezone, currency, rounding_minutes, single_active_timer, allow_self_register, idle_timeout_minutes, backup_retention_days, backup_time, export_delimiter, company_name, company_address, company_email, company_phone, company_website, company_logo_filename, company_tax_id, company_bank_info, invoice_prefix, invoice_start_number, invoice_terms, invoice_notes)
VALUES ('Europe/Rome', 'EUR', 1, true, true, 30, 30, '02:00', ',', 'Your Company Name', 'Your Company Address', 'info@yourcompany.com', '+1 (555) 123-4567', 'www.yourcompany.com', '', '', '', 'INV', 1000, 'Payment is due within 30 days of invoice date.', 'Thank you for your business!')
ON CONFLICT (id) DO NOTHING;
-- Insert default settings only if none exist
INSERT INTO settings (
timezone, currency, rounding_minutes, single_active_timer, allow_self_register,
idle_timeout_minutes, backup_retention_days, backup_time, export_delimiter,
company_name, company_address, company_email, company_phone, company_website,
company_logo_filename, company_tax_id, company_bank_info, invoice_prefix,
invoice_start_number, invoice_terms, invoice_notes
)
SELECT 'Europe/Rome', 'EUR', 1, true, true, 30, 30, '02:00', ',',
'Your Company Name', 'Your Company Address', 'info@yourcompany.com',
'+1 (555) 123-4567', 'www.yourcompany.com', '', '', '', 'INV', 1000,
'Payment is due within 30 days of invoice date.', 'Thank you for your business!'
WHERE NOT EXISTS (
SELECT 1 FROM settings
);
"""
try:

View File

@@ -130,7 +130,7 @@ existing governing policies.
* [Contributing Guidelines](CONTRIBUTING.md)
* [Project Documentation](README.md)
* [Community Guidelines](https://github.com/yourusername/TimeTracker/discussions)
* [Community Guidelines](https://github.com/drytrix/TimeTracker/discussions)
## License

View File

@@ -61,7 +61,7 @@ This project and everyone participating in it is governed by our [Code of Conduc
1. Clone the repository:
```bash
git clone https://github.com/yourusername/TimeTracker.git
git clone https://github.com/drytrix/TimeTracker.git
cd TimeTracker
```

View File

@@ -24,16 +24,17 @@ This guide explains how to set up and use the public Docker image for TimeTracke
**Linux/macOS:**
```bash
git clone https://github.com/yourusername/TimeTracker.git
git clone https://github.com/drytrix/TimeTracker.git
cd TimeTracker
./deploy-public.sh
# Remote production image (latest)
docker-compose -f docker-compose.remote.yml up -d
```
**Windows:**
```cmd
git clone https://github.com/yourusername/TimeTracker.git
git clone https://github.com/drytrix/TimeTracker.git
cd TimeTracker
deploy-public.bat
docker-compose -f docker-compose.remote.yml up -d
```
#### Option B: Manual Deployment
@@ -44,10 +45,7 @@ deploy-public.bat
cd TimeTracker
```
2. **Set your GitHub repository:**
```bash
export GITHUB_REPOSITORY="yourusername/timetracker"
```
2. (Optional) **Use a specific version tag:** set the tag in `docker-compose.remote.yml`.
3. **Create environment file:**
```bash
@@ -57,8 +55,8 @@ deploy-public.bat
4. **Pull and run the image:**
```bash
docker pull ghcr.io/$GITHUB_REPOSITORY:latest
docker-compose -f docker-compose.public.yml up -d
docker pull ghcr.io/drytrix/timetracker:latest
docker-compose -f docker-compose.remote.yml up -d
```
## 🔧 Configuration
@@ -86,11 +84,11 @@ DATABASE_URL=postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker
### Docker Compose Configuration
The `docker-compose.public.yml` file includes:
Use `docker-compose.remote.yml` (production) or `docker-compose.remote-dev.yml` (testing):
- **TimeTracker App**: Main application container
- **PostgreSQL Database**: Persistent data storage
- **Caddy Reverse Proxy**: Optional HTTPS support
- **App**: `ghcr.io/drytrix/timetracker` image
- **PostgreSQL**: Database service with healthcheck
- **Ports**: App exposed on 8080 by default
## 📦 Available Images
@@ -114,10 +112,10 @@ The public image is automatically updated when you push to the main branch. To u
```bash
# Pull the latest image
docker pull ghcr.io/yourusername/timetracker:latest
docker pull ghcr.io/drytrix/timetracker:latest
# Restart the containers
docker-compose -f docker-compose.public.yml up -d
docker-compose -f docker-compose.remote.yml up -d
```
### Manual Updates
@@ -126,11 +124,11 @@ For specific versions:
```bash
# Pull a specific version
docker pull ghcr.io/yourusername/timetracker:v1.0.0
docker pull ghcr.io/drytrix/timetracker:v1.0.0
# Update docker-compose.public.yml to use the specific tag
# Update docker-compose.remote.yml to use the specific tag
# Then restart
docker-compose -f docker-compose.public.yml up -d
docker-compose -f docker-compose.remote.yml up -d
```
## 🛠️ Troubleshooting
@@ -168,23 +166,23 @@ docker-compose -f docker-compose.public.yml up -d
```bash
# List available images
docker images ghcr.io/yourusername/timetracker
docker images ghcr.io/drytrix/timetracker
# Inspect image details
docker inspect ghcr.io/yourusername/timetracker:latest
docker inspect ghcr.io/drytrix/timetracker:latest
```
#### View Logs
```bash
# Application logs
docker-compose -f docker-compose.public.yml logs app
docker-compose -f docker-compose.remote.yml logs app
# Database logs
docker-compose -f docker-compose.public.yml logs db
docker-compose -f docker-compose.remote.yml logs db
# All logs
docker-compose -f docker-compose.public.yml logs -f
docker-compose -f docker-compose.remote.yml logs -f
```
#### Health Check
@@ -194,7 +192,7 @@ docker-compose -f docker-compose.public.yml logs -f
curl http://localhost:8080/_health
# Check container status
docker-compose -f docker-compose.public.yml ps
docker-compose -f docker-compose.remote.yml ps
```
## 🔒 Security Considerations
@@ -240,10 +238,10 @@ docker ps --format "table {{.Names}}\t{{.Status}}"
```bash
# Follow application logs
docker-compose -f docker-compose.public.yml logs -f app
docker-compose -f docker-compose.remote.yml logs -f app
# Export logs for analysis
docker-compose -f docker-compose.public.yml logs app > timetracker.log
docker-compose -f docker-compose.remote.yml logs app > timetracker.log
```
## 🚀 Production Deployment
@@ -263,7 +261,7 @@ docker-compose -f docker-compose.public.yml logs app > timetracker.log
version: '3.8'
services:
app:
image: ghcr.io/yourusername/timetracker:v1.0.0
image: ghcr.io/drytrix/timetracker:v1.0.0
environment:
- SECRET_KEY=${SECRET_KEY}
- ADMIN_USERNAMES=${ADMIN_USERNAMES}

View File

@@ -13,14 +13,17 @@ This error typically occurs due to one of these issues:
## Solutions
### Solution 1: Use the Fixed Dockerfile (Recommended)
### Solution 1: Use the Remote Compose (Recommended)
```bash
# Use the fixed Dockerfile that addresses all issues
docker-compose -f docker-compose.fixed.yml up --build
# Use the production remote compose with prebuilt image
docker-compose -f docker-compose.remote.yml up -d
```
### Solution 2: Fix the Original Dockerfile
The original Dockerfile has been updated to use `start-new.sh` instead of `start-fixed.sh`.
### Solution 2: Rebuild Locally
The provided `Dockerfile` supports local builds. If you prefer rebuilding:
```bash
docker-compose up --build -d
```
### Solution 3: Manual Fix
If you want to fix it manually:
@@ -69,18 +72,18 @@ docker-compose build --no-cache
```
## File Structure
- `Dockerfile.fixed` - Fixed version addressing all issues
- `docker-compose.fixed.yml` - Uses the fixed Dockerfile
- `Dockerfile` - Container build file
- `docker/start.sh` - Startup wrapper
- `docker/start-simple.sh` - Simple, reliable startup script
- `docker/start-new.sh` - Enhanced startup script with schema fixes
- `docker/start-fixed.sh` - Enhanced startup script with schema fixes
## Quick Test
```bash
# Test with the fixed version
docker-compose -f docker-compose.fixed.yml up --build
# Test remote production image
docker-compose -f docker-compose.remote.yml up -d
# Or test with the original (after fixes)
docker-compose up --build
# Or build locally
docker-compose up --build -d
```
## Common Issues and Fixes

View File

@@ -14,9 +14,10 @@ TimeTracker/
├── 📁 .github/ # GitHub workflows and configurations
├── 📁 logs/ # Application logs (with .gitkeep)
├── 🐳 Dockerfile # Main Dockerfile
├── 🐳 Dockerfile.simple # Simple container Dockerfile
├── 📄 docker-compose.simple.yml # Simple container setup
├── 📄 docker-compose.public.yml # Public container setup
├── 🐳 Dockerfile # Main Dockerfile
├── 📄 docker-compose.yml # Local development compose
├── 📄 docker-compose.remote.yml # Remote/production compose (ghcr.io)
├── 📄 docker-compose.remote-dev.yml # Remote dev/testing compose (ghcr.io)
├── 📄 requirements.txt # Python dependencies
├── 📄 app.py # Application entry point
├── 📄 env.example # Environment variables template
@@ -80,17 +81,20 @@ TimeTracker/
## 🚀 Deployment Options
### 1. Simple Container (Recommended)
- **File**: `docker-compose.simple.yml`
- **Dockerfile**: `Dockerfile.simple`
- **Features**: All-in-one with PostgreSQL database
### 1. Local Development
- **File**: `docker-compose.yml`
- **Image**: Built from local source
- **Use case**: Developer workstation
### 2. Remote/Production
- **File**: `docker-compose.remote.yml`
- **Image**: `ghcr.io/drytrix/timetracker:latest` (or versioned tag)
- **Use case**: Production deployment
### 2. Public Container
- **File**: `docker-compose.public.yml`
- **Dockerfile**: `Dockerfile`
- **Features**: External database configuration
- **Use case**: Development and testing
### 3. Remote Dev/Testing
- **File**: `docker-compose.remote-dev.yml`
- **Image**: `ghcr.io/drytrix/timetracker:development`
- **Use case**: Pre-release testing
## 📚 Documentation Files
@@ -135,9 +139,9 @@ The Task Management feature is fully integrated into the application with automa
## 🎯 Getting Started
1. **Choose deployment type**: Simple container (recommended) or public container
1. **Choose deployment type**: Local dev, remote, or remote-dev
2. **Follow README.md**: Complete setup instructions
3. **Use appropriate compose file**: `docker-compose.simple.yml` or `docker-compose.public.yml`
3. **Use appropriate compose file**: `docker-compose.yml`, `docker-compose.remote.yml`, or `docker-compose.remote-dev.yml`
4. **Configure timezone**: Access admin settings to set your local timezone
## 🔍 File Purposes

View File

@@ -20,7 +20,7 @@ Welcome to the TimeTracker documentation. This directory contains comprehensive
- **[INVOICE_FEATURE_README.md](INVOICE_FEATURE_README.md)** - Invoice system documentation
- **[ENHANCED_INVOICE_SYSTEM_README.md](ENHANCED_INVOICE_SYSTEM_README.md)** - Advanced invoice features
- **[INVOICE_INTERFACE_IMPROVEMENTS.md](INVOICE_INTERFACE_IMPROVEMENTS.md)** - Invoice UI improvements
- **[ANALYTICS_FEATURE.md](ANALYTICS_FEATURE.md)** - Analytics and reporting features
- Analytics and reporting features (see the Reports section in the app)
- **[MOBILE_IMPROVEMENTS.md](MOBILE_IMPROVEMENTS.md)** - Mobile interface enhancements
### 🛠️ Technical Details

View File

@@ -168,13 +168,54 @@
</div>
</div>
{% set tax_present = invoice.tax_rate > 0 %}
<!-- Totals Summary -->
<div class="row mb-4">
<div class="{{ 'col-md-4' if tax_present else 'col-md-6' }}">
<div class="card summary-card border-0 shadow-sm">
<div class="card-body d-flex align-items-center">
<div class="summary-icon bg-primary text-white me-3"><i class="fas fa-coins"></i></div>
<div>
<div class="summary-label">Subtotal</div>
<div class="summary-value">{{ "%.2f"|format(invoice.subtotal) }} {{ currency }}</div>
</div>
</div>
</div>
</div>
{% if tax_present %}
<div class="col-md-4">
<div class="card summary-card border-0 shadow-sm">
<div class="card-body d-flex align-items-center">
<div class="summary-icon bg-warning text-white me-3"><i class="fas fa-percent"></i></div>
<div>
<div class="summary-label">Tax ({{ "%.2f"|format(invoice.tax_rate) }}%)</div>
<div class="summary-value">{{ "%.2f"|format(invoice.tax_amount) }} {{ currency }}</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="{{ 'col-md-4' if tax_present else 'col-md-6' }}">
<div class="card summary-card border-0 shadow-sm">
<div class="card-body d-flex align-items-center">
<div class="summary-icon bg-success text-white me-3"><i class="fas fa-sack-dollar"></i></div>
<div>
<div class="summary-label">Total Amount</div>
<div class="summary-value text-success">{{ "%.2f"|format(invoice.total_amount) }} {{ currency }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Enhanced Invoice Items -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<h6 class="m-0 font-weight-bold text-primary d-flex align-items-center">
<i class="fas fa-list me-2"></i>Invoice Items
<span class="badge badge-soft-secondary ms-2">{{ invoice.items.count() }} items</span>
</h6>
{% if invoice.status == 'draft' %}
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="btn btn-sm btn-outline-primary">
@@ -479,6 +520,29 @@
opacity: 0.5;
}
.summary-card .summary-label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.4px;
}
.summary-card .summary-value {
font-size: 20px;
font-weight: 800;
color: var(--text-primary);
}
.summary-card .summary-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
}
@media (max-width: 768px) {
.btn-group {
flex-direction: column;

View File

@@ -37,6 +37,19 @@
{% endif %}
<h1 class="h2 mb-2">Help & Documentation</h1>
<p class="text-muted">TimeTracker by <strong>DryTrix</strong></p>
<div class="d-flex justify-content-center gap-2 mt-2">
<a href="{{ url_for('timer.manual_entry') }}" class="btn btn-primary btn-sm">
<i class="fas fa-play"></i> Log Time
</a>
<a href="{{ url_for('reports.reports') }}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-chart-bar"></i> View Reports
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.settings') }}" class="btn btn-outline-info btn-sm">
<i class="fas fa-sliders-h"></i> System Settings
</a>
{% endif %}
</div>
</div>
<!-- Getting Started -->
@@ -54,9 +67,9 @@
<h5>Navigation</h5>
<ul>
<li><strong>Dashboard:</strong> Overview of your current timer and recent activity</li>
<li><strong>Timer:</strong> Start, stop, and manage time tracking</li>
<li><strong>Projects:</strong> View and manage projects (admin only)</li>
<li><strong>Reports:</strong> Generate time reports and export data</li>
<li><strong>Timer:</strong> Start, stop, and manage time tracking (<a href="{{ url_for('timer.manual_entry') }}">Log Time</a>)</li>
<li><strong>Projects:</strong> View and manage projects{% if current_user.is_admin %} (<a href="{{ url_for('projects.list_projects') }}">Open Projects</a>){% endif %}</li>
<li><strong>Reports:</strong> Generate time reports and export data (<a href="{{ url_for('reports.reports') }}">Open Reports</a>)</li>
{% if current_user.is_admin %}
<li><strong>Admin:</strong> Manage users and system settings</li>
{% endif %}
@@ -153,7 +166,7 @@
</ul>
<h5>Exporting Data</h5>
<p>Use the CSV export feature to download time entries for use in external tools like Excel or accounting software.</p>
<p>Use the CSV export feature to download time entries for external tools. Find it on the Reports page.</p>
</div>
</div>
</section>

View File

@@ -1,53 +1,57 @@
{% extends "base.html" %}
{% block title %}New Project - {{ app_name }}{% endblock %}
{% block title %}Create Project - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="container mt-4">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('projects.list_projects') }}">Projects</a></li>
<li class="breadcrumb-item active">New</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-project-diagram text-primary"></i> New Project
</h1>
</div>
<div>
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Projects
</a>
<div class="card mobile-card">
<div class="card-body py-4">
<div class="d-flex align-items-center">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 48px; height: 48px;">
<i class="fas fa-project-diagram text-primary fa-lg"></i>
</div>
<div>
<h1 class="h2 mb-1">Create New Project</h1>
<p class="text-muted mb-0">Set up a new project to organize your work and track time effectively</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create Project Form -->
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card mobile-card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-info-circle"></i> Project Information
</h5>
<h6 class="mb-0">
<i class="fas fa-edit me-2 text-primary"></i>Project Information
</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('projects.create_project') }}" novalidate>
<div class="row">
<form method="POST" action="{{ url_for('projects.create_project') }}" novalidate id="createProjectForm">
<!-- Project Name and Client -->
<div class="row mb-4">
<div class="col-md-8">
<div class="mb-3">
<label for="name" class="form-label">Project Name *</label>
<input type="text" class="form-control" id="name" name="name" required value="{{ request.form.get('name','') }}">
<label for="name" class="form-label fw-semibold">
<i class="fas fa-tag me-2 text-primary"></i>Project Name <span class="text-danger">*</span>
</label>
<input type="text" class="form-control form-control-lg" id="name" name="name" required
value="{{ request.form.get('name','') }}" placeholder="Enter a descriptive project name">
<small class="form-text text-muted">Choose a clear, descriptive name that explains the project scope</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="client_id" class="form-label">Client *</label>
<select class="form-control" id="client_id" name="client_id" required>
<label for="client_id" class="form-label fw-semibold">
<i class="fas fa-user me-2 text-info"></i>Client <span class="text-danger">*</span>
</label>
<select class="form-select form-select-lg" id="client_id" name="client_id" required>
<option value="">Select a client...</option>
{% for client in clients %}
<option value="{{ client.id }}"
@@ -66,54 +70,312 @@
</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="3">{{ request.form.get('description','') }}</textarea>
<!-- Description -->
<div class="mb-4">
<label for="description" class="form-label fw-semibold">
<i class="fas fa-align-left me-2 text-primary"></i>Description
</label>
<textarea class="form-control" id="description" name="description" rows="4"
placeholder="Provide detailed information about the project, objectives, and deliverables...">{{ request.form.get('description','') }}</textarea>
<small class="form-text text-muted">Optional: Add context, objectives, or specific requirements for the project</small>
</div>
<div class="row">
<!-- Billing Settings -->
<div class="row mb-4">
<div class="col-md-6">
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if request.form.get('billable') %}checked{% endif %}>
<label class="form-check-label" for="billable">Billable</label>
<label class="form-check-label fw-semibold" for="billable">
<i class="fas fa-dollar-sign me-2 text-success"></i>Billable Project
</label>
<small class="form-text text-muted d-block">Enable billing for this project</small>
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="hourly_rate" class="form-label">Hourly Rate</label>
<input type="number" step="0.01" min="0" class="form-control" id="hourly_rate" name="hourly_rate" value="{{ request.form.get('hourly_rate','') }}" placeholder="e.g. 75.00">
<div class="form-text">Leave empty for non-billable projects</div>
<label for="hourly_rate" class="form-label fw-semibold">
<i class="fas fa-clock me-2 text-warning"></i>Hourly Rate
</label>
<input type="number" step="0.01" min="0" class="form-control" id="hourly_rate" name="hourly_rate"
value="{{ request.form.get('hourly_rate','') }}" placeholder="e.g. 75.00">
<small class="form-text text-muted">Leave empty for non-billable projects</small>
</div>
</div>
</div>
<div class="mb-3">
<label for="billing_ref" class="form-label">Billing Reference</label>
<input type="text" class="form-control" id="billing_ref" name="billing_ref" value="{{ request.form.get('billing_ref','') }}" placeholder="Optional">
<!-- Billing Reference -->
<div class="mb-4">
<label for="billing_ref" class="form-label fw-semibold">
<i class="fas fa-receipt me-2 text-secondary"></i>Billing Reference
</label>
<input type="text" class="form-control" id="billing_ref" name="billing_ref"
value="{{ request.form.get('billing_ref','') }}" placeholder="PO number, contract reference, etc.">
<small class="form-text text-muted">Optional: Add a reference number or identifier for billing purposes</small>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Create Project
<!-- Form Actions -->
<div class="d-flex gap-3 pt-3 border-top">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-save me-2"></i>Create Project
</button>
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-outline-secondary btn-lg">
<i class="fas fa-times me-2"></i>Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<!-- Sidebar Help -->
<div class="col-lg-4">
<!-- Project Creation Tips -->
<div class="card mobile-card mb-4">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-lightbulb me-2 text-warning"></i>Project Creation Tips
</h6>
</div>
<div class="card-body">
<div class="tip-item mb-3">
<div class="d-flex align-items-start">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
<i class="fas fa-check text-primary fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Clear Naming</small>
<small class="text-muted">Use descriptive names that clearly indicate the project's purpose</small>
</div>
</div>
</div>
<div class="tip-item mb-3">
<div class="d-flex align-items-start">
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
<i class="fas fa-dollar-sign text-success fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Billing Setup</small>
<small class="text-muted">Set appropriate hourly rates based on project complexity and client budget</small>
</div>
</div>
</div>
<div class="tip-item mb-3">
<div class="d-flex align-items-start">
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
<i class="fas fa-info-circle text-info fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Detailed Description</small>
<small class="text-muted">Include project objectives, deliverables, and key requirements</small>
</div>
</div>
</div>
<div class="tip-item">
<div class="d-flex align-items-start">
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
<i class="fas fa-user text-warning fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Client Selection</small>
<small class="text-muted">Choose the right client to ensure proper project organization</small>
</div>
</div>
</div>
</div>
</div>
<!-- Billing Guide -->
<div class="card mobile-card mb-4">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-info-circle me-2 text-info"></i>Billing Guide
</h6>
</div>
<div class="card-body">
<div class="billing-guide-item mb-3">
<div class="d-flex align-items-center mb-2">
<span class="billing-badge billing-billable me-2">Billable</span>
<small class="text-muted">Track time and bill client</small>
</div>
</div>
<div class="billing-guide-item mb-3">
<div class="d-flex align-items-center mb-2">
<span class="billing-badge billing-non-billable me-2">Non-Billable</span>
<small class="text-muted">Track time without billing</small>
</div>
</div>
<div class="billing-guide-item">
<div class="d-flex align-items-center mb-2">
<span class="billing-badge billing-rate me-2">Rate Setting</span>
<small class="text-muted">Set appropriate hourly rates</small>
</div>
</div>
</div>
</div>
<!-- Project Management -->
<div class="card mobile-card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-tasks me-2 text-secondary"></i>Project Management
</h6>
</div>
<div class="card-body">
<div class="management-item mb-3">
<div class="d-flex align-items-start">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
<i class="fas fa-clock text-primary fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Time Tracking</small>
<small class="text-muted">Log time entries for accurate project tracking</small>
</div>
</div>
</div>
<div class="management-item mb-3">
<div class="d-flex align-items-start">
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
<i class="fas fa-list text-success fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Task Creation</small>
<small class="text-muted">Break down projects into manageable tasks</small>
</div>
</div>
</div>
<div class="management-item">
<div class="d-flex align-items-start">
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
<i class="fas fa-chart-bar text-info fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Progress Monitoring</small>
<small class="text-muted">Track project progress and time allocation</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
/* Billing Badges */
.billing-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.billing-billable {
background-color: #dcfce7;
color: #166534;
}
.billing-non-billable {
background-color: #e2e8f0;
color: #475569;
}
.billing-rate {
background-color: #fef3c7;
color: #92400e;
}
/* Tip Items */
.tip-item {
padding: 0.75rem;
border-radius: 8px;
background-color: #f8fafc;
transition: all 0.2s ease;
}
.tip-item:hover {
background-color: #f1f5f9;
transform: translateX(4px);
}
/* Billing and Management Guide Items */
.billing-guide-item,
.management-item {
padding: 0.5rem;
border-radius: 6px;
transition: all 0.2s ease;
}
.billing-guide-item:hover,
.management-item:hover {
background-color: #f8fafc;
}
/* Form Styling */
.form-control:focus,
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
}
.form-control-lg {
min-height: 56px;
}
.form-select-lg {
min-height: 56px;
}
/* Mobile Optimizations */
@media (max-width: 768px) {
.card-header {
padding: 1rem 1rem 0.75rem 1rem;
}
.card-body {
padding: 0.75rem 1rem;
}
.tip-item:hover {
transform: none;
}
.billing-guide-item:hover,
.management-item:hover {
background-color: transparent;
}
}
/* Hover Effects */
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.tip-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const clientSelect = document.getElementById('client_id');
const hourlyRateInput = document.getElementById('hourly_rate');
const billableCheckbox = document.getElementById('billable');
const form = document.getElementById('createProjectForm');
const nameInput = document.getElementById('name');
// Auto-fill hourly rate from client default
clientSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const defaultRate = selectedOption.getAttribute('data-default-rate');
@@ -122,6 +384,76 @@ document.addEventListener('DOMContentLoaded', function() {
hourlyRateInput.value = defaultRate;
}
});
// Toggle hourly rate field based on billable checkbox
billableCheckbox.addEventListener('change', function() {
if (this.checked) {
hourlyRateInput.removeAttribute('disabled');
hourlyRateInput.classList.remove('text-muted');
} else {
hourlyRateInput.setAttribute('disabled', 'disabled');
hourlyRateInput.classList.add('text-muted');
hourlyRateInput.value = '';
}
});
// Form validation and enhancement
form.addEventListener('submit', function(e) {
const name = nameInput.value.trim();
const clientId = clientSelect.value;
if (!name || !clientId) {
e.preventDefault();
if (!name) {
nameInput.focus();
nameInput.classList.add('is-invalid');
}
if (!clientId) {
clientSelect.focus();
clientSelect.classList.add('is-invalid');
}
return false;
}
// Show loading state
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Creating...';
submitBtn.disabled = true;
// Re-enable after a delay (in case of validation errors)
setTimeout(() => {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
}, 5000);
});
// Real-time validation feedback
nameInput.addEventListener('input', function() {
if (this.value.trim()) {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid');
}
});
clientSelect.addEventListener('change', function() {
if (this.value) {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid');
}
});
// Initialize form state
if (billableCheckbox.checked) {
hourlyRateInput.removeAttribute('disabled');
} else {
hourlyRateInput.setAttribute('disabled', 'disabled');
hourlyRateInput.classList.add('text-muted');
}
});
</script>
{% endblock %}

View File

@@ -2,50 +2,361 @@
{% block title %}Edit Time Entry - {{ app_name }}{% endblock %}
{% block extra_js %}
{% if current_user.is_admin %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const projectSelect = document.getElementById('project_id');
const taskSelect = document.getElementById('task_id');
const form = document.querySelector('form');
if (projectSelect && taskSelect) {
projectSelect.addEventListener('change', function() {
const projectId = this.value;
// Clear current tasks
taskSelect.innerHTML = '<option value="">No Task</option>';
if (projectId) {
// Fetch tasks for the selected project
fetch(`/api/projects/${projectId}/tasks`, { credentials: 'same-origin' })
.then(response => response.json())
.then(data => {
if (data.success && data.tasks) {
data.tasks.forEach(task => {
const option = document.createElement('option');
option.value = task.id;
option.textContent = task.name;
taskSelect.appendChild(option);
});
}
})
.catch(error => {
console.error('Error fetching tasks:', error);
});
}
});
}
// Add form submission confirmation for admin users (custom modal)
if (form) {
form.addEventListener('submit', function(e) {
// If we already confirmed, let it proceed
if (form.dataset.confirmed === '1') {
delete form.dataset.confirmed;
return true;
}
const originalProject = '{{ timer.project.name }}';
const selectedProject = projectSelect.options[projectSelect.selectedIndex].text;
const originalStart = '{{ timer.start_time.strftime("%Y-%m-%d %H:%M") }}';
const originalEnd = '{{ timer.end_time.strftime("%Y-%m-%d %H:%M") if timer.end_time else "Running" }}';
const originalNotes = {{ (timer.notes or '')|tojson }};
const originalTags = {{ (timer.tags or '')|tojson }};
const originalBillable = {{ 'true' if timer.billable else 'false' }};
const startDate = document.getElementById('start_date').value;
const startTime = document.getElementById('start_time').value;
const endDate = document.getElementById('end_date').value;
const endTime = document.getElementById('end_time').value;
const notesVal = document.getElementById('notes').value || '';
const tagsVal = document.getElementById('tags').value || '';
const billableVal = document.getElementById('billable').checked;
const newStart = startDate && startTime ? `${startDate} ${startTime}` : originalStart;
const newEnd = endDate && endTime ? `${endDate} ${endTime}` : originalEnd;
const changes = [];
if (originalProject !== selectedProject) changes.push({ label: 'Project', from: originalProject, to: selectedProject });
if (originalStart !== newStart) changes.push({ label: 'Start', from: originalStart, to: newStart });
if (originalEnd !== newEnd) changes.push({ label: 'End', from: originalEnd, to: newEnd });
if (originalNotes !== notesVal) changes.push({ label: 'Notes', from: originalNotes || '—', to: notesVal || '—' });
if (originalTags !== tagsVal) changes.push({ label: 'Tags', from: originalTags || '—', to: tagsVal || '—' });
if ((originalBillable ? true : false) !== billableVal) changes.push({ label: 'Billable', from: originalBillable ? 'Yes' : 'No', to: billableVal ? 'Yes' : 'No' });
if (changes.length > 0) {
e.preventDefault();
const modalEl = document.getElementById('confirmChangesModal');
const summaryEl = document.getElementById('confirmChangesSummary');
const confirmBtn = document.getElementById('confirmChangesConfirmBtn');
summaryEl.innerHTML = changes.map(ch => `
<div class="mb-2">
<div class="small text-muted">${ch.label}</div>
<div><span class="text-danger">${ch.from}</span> <i class="fas fa-arrow-right mx-2"></i> <span class="text-success">${ch.to}</span></div>
</div>
`).join('');
confirmBtn.onclick = function() {
const inst = bootstrap.Modal.getInstance(modalEl);
if (inst) inst.hide();
form.dataset.confirmed = '1';
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
};
// Ensure modal is attached to body to avoid stacking/pointer issues
try {
if (modalEl.parentElement !== document.body) {
document.body.appendChild(modalEl);
}
} catch (e) {}
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static', keyboard: false });
bsModal.show();
// Focus confirm button when modal is shown
modalEl.addEventListener('shown.bs.modal', function onShown() {
confirmBtn.focus();
modalEl.removeEventListener('shown.bs.modal', onShown);
});
// Handle Enter/Escape keys inside modal
modalEl.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
confirmBtn.click();
}
});
}
});
}
// Ensure admin save button programmatically submits the form (avoids any other JS interference)
const adminSaveBtn = document.getElementById('adminEditSaveBtn');
if (adminSaveBtn && form) {
adminSaveBtn.addEventListener('click', function(ev) {
ev.preventDefault();
if (typeof form.checkValidity === 'function' && !form.checkValidity()) {
if (typeof form.reportValidity === 'function') form.reportValidity();
return;
}
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
});
}
// Live update duration when date/time fields change (admin form)
const startDate = document.getElementById('start_date');
const startTime = document.getElementById('start_time');
const endDate = document.getElementById('end_date');
const endTime = document.getElementById('end_time');
const durationLabel = document.getElementById('adminEditDuration');
function updateDuration() {
if (!startDate || !startTime || !endDate || !endTime || !durationLabel) return;
const sd = startDate.value;
const st = startTime.value;
const ed = endDate.value;
const et = endTime.value;
if (!sd || !st || !ed || !et) {
durationLabel.textContent = '--:--:--';
return;
}
const s = new Date(`${sd}T${st}`);
const e = new Date(`${ed}T${et}`);
const diff = Math.max(0, Math.floor((e - s) / 1000));
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
const srem = diff % 60;
durationLabel.textContent = `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}:${srem.toString().padStart(2,'0')}`;
}
if (startDate && startTime && endDate && endTime) {
startDate.addEventListener('change', updateDuration);
startTime.addEventListener('change', updateDuration);
endDate.addEventListener('change', updateDuration);
endTime.addEventListener('change', updateDuration);
}
});
</script>
{% endif %}
{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex align-items-center">
<i class="fas fa-edit me-2 text-primary"></i>
<h5 class="mb-0">Edit Time Entry</h5>
<div class="card-header d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<i class="fas fa-edit me-2 text-primary"></i>
<h5 class="mb-0">Edit Time Entry</h5>
</div>
{% if current_user.is_admin %}
<span class="badge bg-warning text-dark">
<i class="fas fa-shield-alt me-1"></i>Admin Mode
</span>
{% endif %}
</div>
<div class="card-body">
<div class="mb-4">
<div class="row g-3">
<div class="col-md-6">
<div class="form-control-plaintext">
<strong>Project:</strong> {{ timer.project.name }}
{% if current_user.is_admin %}
<!-- Admin view with editable fields -->
<div class="alert alert-info mb-3">
<i class="fas fa-info-circle me-2"></i>
<strong>Admin Mode:</strong> You can edit all fields of this time entry, including project, task, start/end times, and source.
</div>
<form method="POST" action="{{ url_for('timer.edit_timer', timer_id=timer.id) }}">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="project_id" class="form-label fw-semibold">
<i class="fas fa-project-diagram me-1"></i>Project
</label>
<select class="form-select" id="project_id" name="project_id" required>
{% for project in projects %}
<option value="{{ project.id }}" {% if project.id == timer.project_id %}selected{% endif %}>
{{ project.name }}
</option>
{% endfor %}
</select>
<div class="form-text">Select the project this time entry belongs to</div>
</div>
<div class="col-md-6">
<label for="task_id" class="form-label fw-semibold">
<i class="fas fa-tasks me-1"></i>Task (Optional)
</label>
<select class="form-select" id="task_id" name="task_id">
<option value="">No Task</option>
{% for task in tasks %}
<option value="{{ task.id }}" {% if task.id == timer.task_id %}selected{% endif %}>
{{ task.name }}
</option>
{% endfor %}
</select>
<div class="form-text">Select a specific task within the project</div>
</div>
</div>
<!-- Confirm Changes Modal (Admin) -->
<div class="modal fade" id="confirmChangesModal" tabindex="-1" role="dialog" aria-modal="true" data-bs-backdrop="static" data-bs-keyboard="false" style="z-index:1085;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-exclamation-triangle text-warning me-2"></i>Confirm Changes</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-3">You are about to apply the following changes:</p>
<div id="confirmChangesSummary"></div>
<div class="alert alert-warning mt-3 mb-0">
<i class="fas fa-info-circle me-1"></i>
These updates will modify this time entry permanently.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Cancel
</button>
<button type="button" class="btn btn-primary" id="confirmChangesConfirmBtn">
<i class="fas fa-check me-1"></i>Confirm & Save
</button>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="start_date" class="form-label fw-semibold">
<i class="fas fa-clock me-1"></i>Start Date
</label>
<input type="date" class="form-control" id="start_date" name="start_date"
value="{{ timer.start_time.strftime('%Y-%m-%d') }}" required>
<div class="form-text">When the work started</div>
</div>
<div class="col-md-6">
<label for="start_time" class="form-label fw-semibold">
<i class="fas fa-clock me-1"></i>Start Time
</label>
<input type="time" class="form-control" id="start_time" name="start_time"
value="{{ timer.start_time.strftime('%H:%M') }}" required>
<div class="form-text">Time the work started</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="end_date" class="form-label fw-semibold">
<i class="fas fa-stop-circle me-1"></i>End Date
</label>
<input type="date" class="form-control" id="end_date" name="end_date"
value="{{ timer.end_time.strftime('%Y-%m-%d') if timer.end_time else '' }}">
<div class="form-text">When the work ended (leave empty if still running)</div>
</div>
<div class="col-md-6">
<label for="end_time" class="form-label fw-semibold">
<i class="fas fa-stop-circle me-1"></i>End Time
</label>
<input type="time" class="form-control" id="end_time" name="end_time"
value="{{ timer.end_time.strftime('%H:%M') if timer.end_time else '' }}">
<div class="form-text">Time the work ended</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label for="source" class="form-label fw-semibold">
<i class="fas fa-tag me-1"></i>Source
</label>
<select class="form-select" id="source" name="source">
<option value="manual" {% if timer.source == 'manual' %}selected{% endif %}>Manual</option>
<option value="auto" {% if timer.source == 'auto' %}selected{% endif %}>Automatic</option>
</select>
<div class="form-text">How this entry was created</div>
</div>
<div class="col-md-4 d-flex align-items-center">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if timer.billable %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="billable">
<i class="fas fa-dollar-sign me-1"></i>Billable
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-control-plaintext">
<strong>Duration:</strong> <span id="adminEditDuration">{{ timer.duration_formatted }}</span>
</div>
</div>
</div>
{% else %}
<!-- Regular user view (read-only) -->
<div class="row g-3">
<div class="col-md-6">
<div class="form-control-plaintext">
<strong>Project:</strong> {{ timer.project.name }}
</div>
</div>
<div class="col-md-3">
<div class="form-control-plaintext">
<strong>Start:</strong> {{ timer.start_time.strftime('%Y-%m-%d %H:%M') }}
</div>
</div>
<div class="col-md-3">
<div class="form-control-plaintext">
<strong>End:</strong>
{% if timer.end_time %}
{{ timer.end_time.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span class="text-warning">Running</span>
{% endif %}
</div>
</div>
</div>
<div class="col-md-3">
<div class="form-control-plaintext">
<strong>Start:</strong> {{ timer.start_time.strftime('%Y-%m-%d %H:%M') }}
</div>
<div class="mt-2">
<span class="badge bg-primary">Duration: {{ timer.duration_formatted }}</span>
{% if timer.source == 'manual' %}
<span class="badge bg-secondary">Manual</span>
{% else %}
<span class="badge bg-info">Automatic</span>
{% endif %}
</div>
<div class="col-md-3">
<div class="form-control-plaintext">
<strong>End:</strong>
{% if timer.end_time %}
{{ timer.end_time.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span class="text-warning">Running</span>
{% endif %}
</div>
</div>
</div>
<div class="mt-2">
<span class="badge bg-primary">Duration: {{ timer.duration_formatted }}</span>
{% if timer.source == 'manual' %}
<span class="badge bg-secondary">Manual</span>
{% else %}
<span class="badge bg-info">Automatic</span>
{% endif %}
</div>
{% endif %}
</div>
<form method="POST" action="{{ url_for('timer.edit_timer', timer_id=timer.id) }}">
{% if current_user.is_admin %}
<!-- Admin form fields -->
<div class="mb-4">
<label for="notes" class="form-label fw-semibold">
<i class="fas fa-sticky-note me-1"></i>Notes
@@ -63,25 +374,57 @@
<div class="form-text">Separate tags with commas</div>
</div>
</div>
<div class="col-md-4 d-flex align-items-center">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if timer.billable %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="billable">
<i class="fas fa-dollar-sign me-1"></i>Billable
</label>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i>Back
</a>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" id="adminEditSaveBtn">
<i class="fas fa-save me-2"></i>Save Changes
</button>
</div>
</form>
</form>
{% else %}
<!-- Regular user form -->
<form method="POST" action="{{ url_for('timer.edit_timer', timer_id=timer.id) }}">
<div class="mb-4">
<label for="notes" class="form-label fw-semibold">
<i class="fas fa-sticky-note me-1"></i>Notes
</label>
<textarea class="form-control" id="notes" name="notes" rows="3" placeholder="Describe what you worked on">{{ timer.notes or '' }}</textarea>
</div>
<div class="row">
<div class="col-md-8">
<div class="mb-4">
<label for="tags" class="form-label fw-semibold">
<i class="fas fa-tags me-1"></i>Tags
</label>
<input type="text" class="form-control" id="tags" name="tags" placeholder="tag1, tag2" value="{{ timer.tags or '' }}">
<div class="form-text">Separate tags with commas</div>
</div>
</div>
<div class="col-md-4 d-flex align-items-center">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if timer.billable %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="billable">
<i class="fas fa-dollar-sign me-1"></i>Billable
</label>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i>Back
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>Save Changes
</button>
</div>
</form>
{% endif %}
</div>
</div>
</div>

View File

@@ -164,7 +164,7 @@
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="editTimerForm">
<form id="editTimerForm" novalidate>
<div class="modal-body">
<input type="hidden" id="editEntryId" name="entry_id">
<div class="row">
@@ -241,7 +241,7 @@
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Cancel
</button>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" id="editSaveBtn">
<i class="fas fa-save me-2"></i>Save Changes
</button>
</div>
@@ -281,19 +281,21 @@
</div>
{% endblock %}
{% block scripts %}
{% block extra_js %}
<script>
let activeTimer = null;
let timerInterval = null;
// Load projects for dropdowns
function loadProjects() {
fetch('/api/projects')
const promise = fetch('/api/projects', { credentials: 'same-origin' })
.then(response => response.json())
.then(data => {
const projectSelect = document.getElementById('projectSelect');
const editProjectSelect = document.getElementById('editProjectSelect');
if (!projectSelect || !editProjectSelect) return;
// Clear existing options
projectSelect.innerHTML = '<option value="">Select a project...</option>';
editProjectSelect.innerHTML = '<option value="">Select a project...</option>';
@@ -307,16 +309,19 @@ function loadProjects() {
editProjectSelect.add(editOption);
}
});
return data.projects;
})
.catch(error => {
console.error('Error loading projects:', error);
showToast('Error loading projects', 'error');
});
window.projectsLoadedPromise = promise;
return promise;
}
// Check timer status
function checkTimerStatus() {
fetch('/api/timer/status')
fetch('/api/timer/status', { credentials: 'same-origin' })
.then(response => response.json())
.then(data => {
if (data.active && data.timer) {
@@ -378,7 +383,7 @@ function startTimerDisplay() {
// Load recent entries
function loadRecentEntries() {
fetch('/api/entries?limit=10')
fetch('/api/entries?limit=10', { credentials: 'same-origin' })
.then(response => response.json())
.then(data => {
const container = document.getElementById('recentEntriesList');
@@ -460,6 +465,7 @@ document.getElementById('startTimerForm').addEventListener('submit', function(e)
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(data)
})
.then(response => response.json())
@@ -490,7 +496,8 @@ document.getElementById('stopTimerBtn').addEventListener('click', function() {
this.disabled = true;
fetch('/api/timer/stop', {
method: 'POST'
method: 'POST',
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
@@ -514,43 +521,54 @@ document.getElementById('stopTimerBtn').addEventListener('click', function() {
// Edit entry
function editEntry(entryId) {
fetch(`/api/entry/${entryId}`)
.then(response => response.json())
.then(data => {
document.getElementById('editEntryId').value = entryId;
document.getElementById('editProjectSelect').value = data.project_id;
document.getElementById('editStartTime').value = data.start_time.slice(0, 16);
document.getElementById('editEndTime').value = data.end_time ? data.end_time.slice(0, 16) : '';
const ensureProjects = window.projectsLoadedPromise || loadProjects();
Promise.resolve(ensureProjects).then(() => {
return fetch(`/api/entry/${entryId}`, { credentials: 'same-origin' })
.then(response => response.json());
})
.then(data => {
document.getElementById('editEntryId').value = entryId;
const editProjectSelect = document.getElementById('editProjectSelect');
// Ensure the project option exists; if not, append it
if (!Array.from(editProjectSelect.options).some(o => o.value == data.project_id)) {
const opt = new Option(data.project_name || `Project ${data.project_id}`, data.project_id);
editProjectSelect.add(opt);
}
editProjectSelect.value = data.project_id;
// Convert ISO to local datetime-local value
const startIso = data.start_time;
const endIso = data.end_time;
const toLocalInput = (iso) => {
if (!iso) return '';
const d = new Date(iso);
const pad = (n) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
document.getElementById('editStartTime').value = toLocalInput(startIso);
document.getElementById('editEndTime').value = toLocalInput(endIso);
document.getElementById('editNotes').value = data.notes || '';
document.getElementById('editTags').value = data.tags || '';
document.getElementById('editBillable').checked = data.billable;
// Calculate and display duration
if (data.end_time) {
const start = new Date(data.start_time);
const end = new Date(data.end_time);
const duration = Math.floor((end - start) / 1000);
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = duration % 60;
document.getElementById('editDurationDisplay').textContent =
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
updateEditDurationDisplay();
new bootstrap.Modal(document.getElementById('editTimerModal')).show();
})
.catch(error => {
})
.catch(error => {
console.error('Error loading entry:', error);
showToast('Error loading entry', 'error');
});
});
}
// Edit timer form
document.getElementById('editTimerForm').addEventListener('submit', function(e) {
e.preventDefault();
// Debug: signal that edit form handler is attached
console.debug('Edit form submit handler attached');
function performEditSave() {
const form = document.getElementById('editTimerForm');
if (!form) return;
const entryId = document.getElementById('editEntryId').value;
const formData = new FormData(this);
const formData = new FormData(form);
const data = {
project_id: formData.get('project_id'),
start_time: formData.get('start_time'),
@@ -559,39 +577,87 @@ document.getElementById('editTimerForm').addEventListener('submit', function(e)
tags: formData.get('tags'),
billable: formData.get('billable') === 'on'
};
// Show loading state
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Saving...';
submitBtn.disabled = true;
console.debug('Submitting data', data);
// Validate
const startVal = data.start_time ? new Date(data.start_time) : null;
const endVal = data.end_time ? new Date(data.end_time) : null;
if (startVal && endVal && endVal <= startVal) {
showToast('End time must be after start time', 'error');
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Saving...';
submitBtn.disabled = true;
}
fetch(`/api/entry/${entryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
.then(r => r.json())
.then(payload => {
console.debug('PUT response', payload);
if (payload.success) {
showToast('Entry updated successfully', 'success');
bootstrap.Modal.getInstance(document.getElementById('editTimerModal')).hide();
const modal = bootstrap.Modal.getInstance(document.getElementById('editTimerModal'));
if (modal) modal.hide();
loadRecentEntries();
} else {
showToast(data.message || 'Error updating entry', 'error');
showToast(payload.message || 'Error updating entry', 'error');
}
})
.catch(error => {
console.error('Error updating entry:', error);
.catch(err => {
console.error('Error updating entry:', err);
showToast('Error updating entry', 'error');
})
.finally(() => {
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
submitBtn.disabled = false;
if (submitBtn) {
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
submitBtn.disabled = false;
}
});
}
// Edit timer form
document.getElementById('editTimerForm').addEventListener('submit', function(e) {
e.preventDefault();
console.debug('Edit form submit event fired');
performEditSave();
});
// Ensure click on Save triggers submit handler even if native validation blocks it
document.addEventListener('click', function(e) {
const btn = e.target.closest('#editSaveBtn');
if (!btn) return;
e.preventDefault();
performEditSave();
});
// Live update duration when times change in edit modal
function updateEditDurationDisplay() {
const start = document.getElementById('editStartTime').value;
const end = document.getElementById('editEndTime').value;
const display = document.getElementById('editDurationDisplay');
if (!start || !end) {
display.textContent = '--:--:--';
return;
}
const startDate = new Date(start);
const endDate = new Date(end);
const diff = Math.max(0, Math.floor((endDate - startDate) / 1000));
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
const s = diff % 60;
display.textContent = `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
}
document.getElementById('editStartTime').addEventListener('change', updateEditDurationDisplay);
document.getElementById('editEndTime').addEventListener('change', updateEditDurationDisplay);
// Delete timer
document.getElementById('deleteTimerBtn').addEventListener('click', function() {
const entryId = document.getElementById('editEntryId').value;
@@ -616,7 +682,7 @@ function deleteEntry(entryId) {
// Initialize
document.addEventListener('DOMContentLoaded', function() {
loadProjects();
window.projectsLoadedPromise = loadProjects();
checkTimerStatus();
loadRecentEntries();
@@ -639,7 +705,8 @@ document.addEventListener('DOMContentLoaded', function() {
}
fetch(`/api/entry/${entryId}`, {
method: 'DELETE'
method: 'DELETE',
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {