From ceae30ecb9abab705449fe3dfbc4074b877b2bdf Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 16:23:58 +0100 Subject: [PATCH 01/20] Fix #563: correct route for post-timer toast after Stop & Save - Use timer.time_entries_overview instead of timer.time_entries when building the 'View time entries' URL in the dashboard. The invalid route name caused BuildError and an error page after stopping the timer, even though the time entry was saved. - Document the fix in CHANGELOG under Unreleased / Fixed. --- CHANGELOG.md | 1 + app/routes/main.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 439e658c..2d37b69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Architecture refactor** — API v1 split into per-resource sub-blueprints (projects, tasks, clients, invoices, expenses, payments, mileage, deals, leads, contacts) under `app/routes/api_v1_*.py`; bootstrap slimmed by moving `setup_logging` to `app/utils/setup_logging.py` and legacy migrations to `app/utils/legacy_migrations.py`. Dashboard aggregations (top projects, time-by-project chart) moved into `AnalyticsService` (`get_dashboard_top_projects`, `get_time_by_project_chart`); dashboard route simplified to call services only. ARCHITECTURE.md updated with module table, API structure, and data flow; DEVELOPMENT.md with development workflow and build steps. ### Fixed +- **Stop & Save error (Issue #563)** — Fixed error after clicking "Stop & Save" on the dashboard. The post-timer toast was building the "View time entries" URL with the wrong route name (`timer.time_entries`); the correct endpoint is `timer.time_entries_overview`. Time entries were already saved; the error occurred when rendering the dashboard redirect. - **Dashboard cache (Issue #549)** — Removed dashboard caching that caused "Instance not bound to a Session" and "Database Error" on second visit. Cached template data contained ORM objects (active_timer, recent_entries, top_projects, templates, etc.) that become detached when served in a different request. - **Task description field (Issue #535)** — When creating or editing a task, the description field could appear missing or broken if the Toast UI Editor (loaded from CDN) failed to load (e.g. reverse proxy, CSP, Firefox, or offline). A fallback now shows a plain textarea so users can always enter a description; Markdown is still supported when the rich editor loads. - **ZUGFeRD / PDF/A-3 and PEPPOL (Discussion #433)** — ZUGFeRD embedding no longer silently succeeds without XML when the embed step fails; export is aborted with an actionable error. XMP metadata is created when missing so validators recognize the document. Optional PDF/A-3 normalization (XMP identification and output intent) and optional veraPDF validation gate added. Native PEPPOL transport (SML/SMP + AS4) and strict sender/recipient identifier validation added. diff --git a/app/routes/main.py b/app/routes/main.py index ca189499..2135fe00 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -144,7 +144,7 @@ def dashboard(): # Post-timer toast data (show "Logged Xh on Project" + link to time entries) timer_stopped_toast = session.pop("timer_stopped_toast", None) if timer_stopped_toast: - timer_stopped_toast["time_entries_url"] = url_for("timer.time_entries") + timer_stopped_toast["time_entries_url"] = url_for("timer.time_entries_overview") # Get user stats for smart banner and donation widget try: From 147da2949f38c1246c91bf56db3f1ff4ad7c0b6a Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 16:37:31 +0100 Subject: [PATCH 02/20] Fix(web): prevent mobile browser freeze on Log Time page (Issue #557) On viewports <=767px, skip loading Toast UI Editor for the notes field on manual entry and edit timer pages; use a plain textarea instead. Toast UI is heavy and was freezing/crashing mobile Safari and Chrome. Desktop behavior unchanged. Document in CHANGELOG and MOBILE_IMPROVEMENTS.md. --- CHANGELOG.md | 1 + app/templates/timer/edit_timer.html | 42 +++++++++++++++++++++++---- app/templates/timer/manual_entry.html | 40 +++++++++++++++++++++---- docs/MOBILE_IMPROVEMENTS.md | 3 ++ 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d37b69d..96b0a493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Architecture refactor** — API v1 split into per-resource sub-blueprints (projects, tasks, clients, invoices, expenses, payments, mileage, deals, leads, contacts) under `app/routes/api_v1_*.py`; bootstrap slimmed by moving `setup_logging` to `app/utils/setup_logging.py` and legacy migrations to `app/utils/legacy_migrations.py`. Dashboard aggregations (top projects, time-by-project chart) moved into `AnalyticsService` (`get_dashboard_top_projects`, `get_time_by_project_chart`); dashboard route simplified to call services only. ARCHITECTURE.md updated with module table, API structure, and data flow; DEVELOPMENT.md with development workflow and build steps. ### Fixed +- **Log Time / Edit Time Entry on mobile (Issue #557)** — Opening the manual time entry ("Log Time") or edit time entry page on mobile could freeze or crash the browser. The Toast UI Editor (WYSIWYG markdown editor) for the notes field is heavy and causes freezes on mobile Safari/Chrome. On viewports ≤767px we now skip loading the editor and show a plain textarea for notes instead; desktop behavior is unchanged. Manual entry and edit timer templates load Toast UI only when not in mobile view. - **Stop & Save error (Issue #563)** — Fixed error after clicking "Stop & Save" on the dashboard. The post-timer toast was building the "View time entries" URL with the wrong route name (`timer.time_entries`); the correct endpoint is `timer.time_entries_overview`. Time entries were already saved; the error occurred when rendering the dashboard redirect. - **Dashboard cache (Issue #549)** — Removed dashboard caching that caused "Instance not bound to a Session" and "Database Error" on second visit. Cached template data contained ORM objects (active_timer, recent_entries, top_projects, templates, etc.) that become detached when served in a different request. - **Task description field (Issue #535)** — When creating or editing a task, the description field could appear missing or broken if the Toast UI Editor (loaded from CDN) failed to load (e.g. reverse proxy, CSP, Firefox, or offline). A fallback now shows a plain textarea so users can always enter a description; Markdown is still supported when the rich editor loads. diff --git a/app/templates/timer/edit_timer.html b/app/templates/timer/edit_timer.html index fb1dc2e2..d417a836 100644 --- a/app/templates/timer/edit_timer.html +++ b/app/templates/timer/edit_timer.html @@ -3,8 +3,7 @@ {% block title %}{{ _('Edit Time Entry') }} - {{ app_name }}{% endblock %} {% block extra_css %} - - +{# Toast UI Editor CSS loaded dynamically on desktop only (Issue #557 mobile freeze fix) #} {% endblock %} {% block extra_js %} @@ -532,15 +531,46 @@

- - + + {% endblock %} diff --git a/app/templates/timer/manual_entry.html b/app/templates/timer/manual_entry.html index ac4a9e5e..fa246435 100644 --- a/app/templates/timer/manual_entry.html +++ b/app/templates/timer/manual_entry.html @@ -152,6 +152,22 @@

- - - - - + {% endblock %} diff --git a/docs/MOBILE_IMPROVEMENTS.md b/docs/MOBILE_IMPROVEMENTS.md index f7cce370..0872b963 100644 --- a/docs/MOBILE_IMPROVEMENTS.md +++ b/docs/MOBILE_IMPROVEMENTS.md @@ -50,6 +50,9 @@ The TimeTracker application has been completely redesigned with a mobile-first a - **Efficient Scrolling**: Smooth, optimized scrolling performance - **Mobile-Specific CSS**: Dedicated mobile stylesheets for better performance +### 8. **Log Time and Edit Time Entry on Mobile (Issue #557)** +- **No browser freeze**: The manual time entry ("Log Time") and edit time entry pages no longer load the Toast UI Editor on mobile viewports (≤767px). The rich editor is resource-heavy and could freeze or crash mobile browsers. On mobile, the notes field is a plain textarea; on desktop, the full WYSIWYG editor still loads. Users can create and edit time entries on mobile without freezing the browser. + ## 🛠️ Technical Implementation ### CSS Improvements From de2a7db026c03bf2a2ff5572fdcc54ca55dbbb5a Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 16:49:26 +0100 Subject: [PATCH 03/20] fix: restrict subcontractors to assigned projects/clients when starting timers (fixes #558) - Enforce scope in timer routes: start_timer (POST), start_timer_for_project (GET), and start_timer_from_template; deny with flash+redirect when project/client not allowed - Add user_can_access_project check in api_start_timer (legacy API), API v1 timer/start, and kiosk start-timer; return 403 with clear error message - Scope dashboard Start Timer modal: load active_projects and active_clients via apply_project_scope_to_model/apply_client_scope_to_model so subcontractors only see assigned options - Document timer start scope in SUBCONTRACTOR_ROLE.md (web, API, kiosk, 403/redirect) --- app/routes/api.py | 5 +++++ app/routes/api_v1_time_entries.py | 6 ++++++ app/routes/kiosk.py | 5 +++++ app/routes/main.py | 22 ++++++++++++++-------- app/routes/timer.py | 23 +++++++++++++++++++++++ docs/SUBCONTRACTOR_ROLE.md | 2 +- 6 files changed, 54 insertions(+), 9 deletions(-) diff --git a/app/routes/api.py b/app/routes/api.py index 0f0240d0..c780896e 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -331,6 +331,11 @@ def api_start_timer(): if not project: return jsonify({"error": "Invalid project"}), 400 + from app.utils.scope_filter import user_can_access_project + + if not user_can_access_project(current_user, project_id): + return jsonify({"error": "You do not have access to this project"}), 403 + # Validate task if provided task = None if task_id: diff --git a/app/routes/api_v1_time_entries.py b/app/routes/api_v1_time_entries.py index 86bf904a..0ad3d0f3 100644 --- a/app/routes/api_v1_time_entries.py +++ b/app/routes/api_v1_time_entries.py @@ -247,6 +247,12 @@ def start_timer(): project_id = data.get("project_id") if not project_id: return jsonify({"error": "project_id is required"}), 400 + + from app.utils.scope_filter import user_can_access_project + + if not user_can_access_project(g.api_user, project_id): + return jsonify({"error": "You do not have access to this project"}), 403 + time_tracking_service = TimeTrackingService() result = time_tracking_service.start_timer( user_id=g.api_user.id, diff --git a/app/routes/kiosk.py b/app/routes/kiosk.py index b6ecd9b8..d113071b 100644 --- a/app/routes/kiosk.py +++ b/app/routes/kiosk.py @@ -461,6 +461,11 @@ def kiosk_start_timer(): if not project or project.status != "active": return jsonify({"error": "Invalid or inactive project"}), 400 + from app.utils.scope_filter import user_can_access_project + + if not user_can_access_project(current_user, project_id): + return jsonify({"error": "You do not have access to this project"}), 403 + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: diff --git a/app/routes/main.py b/app/routes/main.py index 2135fe00..42975ff0 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,6 +1,6 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify from flask_login import login_required, current_user -from app.models import User, Project, TimeEntry, Settings, WeeklyTimeGoal, TimeEntryTemplate, Activity +from app.models import User, Project, TimeEntry, Settings, WeeklyTimeGoal, TimeEntryTemplate, Activity, Client from datetime import datetime, timedelta from app import db, track_page_view from sqlalchemy import text @@ -39,13 +39,19 @@ def dashboard(): time_entry_repo = TimeEntryRepository() recent_entries = time_entry_repo.get_by_user(user_id=current_user.id, limit=10, include_relations=True) - # Get active projects for timer dropdown (using repository) - from app.repositories import ProjectRepository, ClientRepository - - project_repo = ProjectRepository() - client_repo = ClientRepository() - active_projects = project_repo.get_active_projects() - active_clients = client_repo.get_active_clients() + # Get active projects and clients for timer dropdown (scoped for subcontractors) + from app.utils.scope_filter import apply_project_scope_to_model, apply_client_scope_to_model + + projects_query = Project.query.filter_by(status="active").order_by(Project.name) + scope_p = apply_project_scope_to_model(Project, current_user) + if scope_p is not None: + projects_query = projects_query.filter(scope_p) + active_projects = projects_query.all() + clients_query = Client.query.filter_by(status="active").order_by(Client.name) + scope_c = apply_client_scope_to_model(Client, current_user) + if scope_c is not None: + clients_query = clients_query.filter(scope_c) + active_clients = clients_query.all() only_one_client = len(active_clients) == 1 single_client = active_clients[0] if only_one_client else None diff --git a/app/routes/timer.py b/app/routes/timer.py index 45d7fa89..ab8a28ac 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -13,6 +13,7 @@ from sqlalchemy.exc import ProgrammingError from app.services.project_service import ProjectService from app.services.client_service import ClientService +from app.utils.scope_filter import user_can_access_project, user_can_access_client _project_service = ProjectService() _client_service = ClientService() @@ -128,6 +129,16 @@ def start_timer(): ) return redirect(url_for("main.dashboard")) + # Subcontractor scope: only allow starting timer on assigned project/client + if project_id and not user_can_access_project(current_user, project_id): + flash(_("You do not have access to this project"), "error") + current_app.logger.warning("Start timer denied: user has no access to project_id=%s", project_id) + return redirect(url_for("main.dashboard")) + if client_id and not project_id and not user_can_access_client(current_user, client_id): + flash(_("You do not have access to this client"), "error") + current_app.logger.warning("Start timer denied: user has no access to client_id=%s", client_id) + return redirect(url_for("main.dashboard")) + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: @@ -300,6 +311,13 @@ def start_timer_from_template(template_id): flash(_("Cannot start timer for this project"), "error") return redirect(url_for("time_entry_templates.list_templates")) + if not user_can_access_project(current_user, template.project_id): + flash(_("You do not have access to this project"), "error") + current_app.logger.warning( + "Start timer from template denied: user has no access to project_id=%s", template.project_id + ) + return redirect(url_for("time_entry_templates.list_templates")) + # Create new timer from template from app.models.time_entry import local_now @@ -376,6 +394,11 @@ def start_timer_for_project(project_id): current_app.logger.warning("Start timer (GET) failed: project_id=%s is not active", project_id) return redirect(url_for("main.dashboard")) + if not user_can_access_project(current_user, project_id): + flash(_("You do not have access to this project"), "error") + current_app.logger.warning("Start timer (GET) denied: user has no access to project_id=%s", project_id) + return redirect(url_for("main.dashboard")) + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: diff --git a/docs/SUBCONTRACTOR_ROLE.md b/docs/SUBCONTRACTOR_ROLE.md index 9c2e01e5..ad71b803 100644 --- a/docs/SUBCONTRACTOR_ROLE.md +++ b/docs/SUBCONTRACTOR_ROLE.md @@ -37,7 +37,7 @@ If you change the role away from Subcontractor, assigned clients are cleared. If - **Clients**: List and detail views; edit client. Other clients are hidden and direct URLs return 403. - **Projects**: List, export, view, edit. Only projects belonging to assigned clients are shown; others 403. -- **Time entries**: Timer (manual entry, edit), time entries report and exports. Only entries for allowed projects are included. +- **Time entries**: Timer start (web POST/GET, from template, legacy API, API v1, kiosk), manual entry, and edit. Starting a timer on a project or client the user is not assigned to returns 403 or a redirect with an error. Time entries report and exports only include allowed projects. - **Invoices**: Create invoice (project dropdown), and invoice data for reports. - **Reports**: All report screens and export form use scoped clients and projects; time entries report only includes allowed projects. - **API v1**: List/get clients and projects, global search, and client contacts are scoped; direct access to other resources returns 403. From ca0c181dc3ee32a075dd989e3b884e728810de93 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:38:56 +0100 Subject: [PATCH 04/20] feat(overtime): add get_overtime_ytd and get_overtime_last_12_months helpers (Issue #560) - get_overtime_ytd(user): returns overtime from Jan 1 through today - get_overtime_last_12_months(user): returns rolling 12-month overtime - Reuses calculate_period_overtime; no new DB columns --- app/utils/overtime.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/utils/overtime.py b/app/utils/overtime.py index dca904b8..5ce3d563 100644 --- a/app/utils/overtime.py +++ b/app/utils/overtime.py @@ -358,6 +358,25 @@ def get_weekly_overtime_summary(user, weeks: int = 4) -> List[Dict]: return weekly_summary +def get_overtime_ytd(user) -> Dict[str, float]: + """ + Return overtime for the current year to date (Jan 1 through today). + Uses calculate_period_overtime; no stored balance. + """ + today = datetime.now().date() + start_ytd = date(today.year, 1, 1) + return calculate_period_overtime(user, start_ytd, today) + + +def get_overtime_last_12_months(user) -> Dict[str, float]: + """ + Return overtime for the last 12 months (rolling). + """ + today = datetime.now().date() + start = today - timedelta(days=365) + return calculate_period_overtime(user, start, today) + + def get_overtime_statistics(user, start_date: date, end_date: date) -> Dict: """ Get comprehensive overtime statistics for a period. From bd31609ea1385a8141ad693f04306e134557e7b2 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:39:10 +0100 Subject: [PATCH 05/20] feat(overtime): show accumulated overtime (YTD) on dashboard and in API (Issue #560) - Main dashboard: compute and display Overtime (YTD) in Month's Hours card - Analytics: GET /api/analytics/overtime supports period=ytd and start_date/end_date - API: dashboard stats endpoints include overtime_ytd_hours in response --- app/routes/analytics.py | 30 +++++++++++++++++++++++------- app/routes/api.py | 12 +++++++++--- app/routes/main.py | 5 ++++- app/templates/main/dashboard.html | 5 +++++ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/app/routes/analytics.py b/app/routes/analytics.py index 91252d71..78e3a482 100644 --- a/app/routes/analytics.py +++ b/app/routes/analytics.py @@ -451,16 +451,29 @@ def weekly_trends(): @login_required @module_enabled("analytics") def overtime_analytics(): - """Get overtime statistics for the current user or all users (if admin)""" - try: - days = int(request.args.get("days", 30)) - except (ValueError, TypeError): - return jsonify({"error": "Invalid days parameter"}), 400 - + """Get overtime statistics for the current user or all users (if admin). + Supports period=ytd (year-to-date), or days=N (last N days), or start_date/end_date.""" from app.utils.overtime import calculate_period_overtime, get_daily_breakdown end_date = datetime.now().date() - start_date = end_date - timedelta(days=days) + period = request.args.get("period", "").lower() + if period == "ytd": + start_date = end_date.replace(month=1, day=1) + else: + start_date_arg = request.args.get("start_date") + end_date_arg = request.args.get("end_date") + if start_date_arg and end_date_arg: + try: + start_date = datetime.strptime(start_date_arg, "%Y-%m-%d").date() + end_date = datetime.strptime(end_date_arg, "%Y-%m-%d").date() + except ValueError: + return jsonify({"error": "Invalid start_date or end_date"}), 400 + else: + try: + days = int(request.args.get("days", 30)) + except (ValueError, TypeError): + return jsonify({"error": "Invalid days parameter"}), 400 + start_date = end_date - timedelta(days=days) # If admin, show all users; otherwise show current user only if current_user.is_admin: @@ -503,6 +516,9 @@ def overtime_analytics(): return jsonify( { + "period": "ytd" if period == "ytd" else "range", + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), "users": user_overtime_data, "summary": { "total_regular_hours": round(total_regular, 2), diff --git a/app/routes/api.py b/app/routes/api.py index c780896e..1e789077 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1513,9 +1513,12 @@ def get_stats(): start_date=start_date.date(), user_id=user_id ) - # Overtime for today and week + # Overtime for today, week, and YTD + from app.utils.overtime import get_overtime_ytd + today_overtime = calculate_period_overtime(current_user, today, today) week_overtime = calculate_period_overtime(current_user, week_start, today) + overtime_ytd = get_overtime_ytd(current_user) standard_hours = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0) return jsonify( @@ -1529,6 +1532,7 @@ def get_stats(): "today_overtime_hours": today_overtime["overtime_hours"], "week_regular_hours": week_overtime["regular_hours"], "week_overtime_hours": week_overtime["overtime_hours"], + "overtime_ytd_hours": overtime_ytd["overtime_hours"], } ) @@ -1830,7 +1834,7 @@ def dashboard_stats(): """Get dashboard statistics for real-time updates""" from app.models import TimeEntry from datetime import datetime, timedelta - from app.utils.overtime import calculate_period_overtime, get_week_start_for_date + from app.utils.overtime import calculate_period_overtime, get_week_start_for_date, get_overtime_ytd today = datetime.utcnow().date() week_start = get_week_start_for_date(today, current_user) @@ -1842,9 +1846,10 @@ def dashboard_stats(): month_hours = TimeEntry.get_total_hours_for_period(start_date=month_start, user_id=current_user.id) - # Overtime for today and week (for dashboard cards) + # Overtime for today, week, and YTD (for dashboard cards) today_overtime = calculate_period_overtime(current_user, today, today) week_overtime = calculate_period_overtime(current_user, week_start, today) + overtime_ytd = get_overtime_ytd(current_user) standard_hours = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0) return jsonify( @@ -1858,6 +1863,7 @@ def dashboard_stats(): "today_overtime_hours": today_overtime["overtime_hours"], "week_regular_hours": week_overtime["regular_hours"], "week_overtime_hours": week_overtime["overtime_hours"], + "overtime_ytd_hours": overtime_ytd["overtime_hours"], } ) diff --git a/app/routes/main.py b/app/routes/main.py index 42975ff0..10152282 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -57,7 +57,7 @@ def dashboard(): # Get user statistics and dashboard aggregations via analytics service from app.services import AnalyticsService - from app.utils.overtime import calculate_period_overtime, get_week_start_for_date + from app.utils.overtime import calculate_period_overtime, get_week_start_for_date, get_overtime_ytd analytics_service = AnalyticsService() stats = analytics_service.get_dashboard_stats(user_id=current_user.id) @@ -70,6 +70,7 @@ def dashboard(): week_start_dt = get_week_start_for_date(today_dt, current_user) today_overtime = calculate_period_overtime(current_user, today_dt, today_dt) week_overtime = calculate_period_overtime(current_user, week_start_dt, today_dt) + overtime_ytd = get_overtime_ytd(current_user) standard_hours_per_day = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0) # Top projects (last 30 days) and time-by-project chart (last 7 days) from service @@ -187,6 +188,8 @@ def dashboard(): "today_overtime_hours": today_overtime["overtime_hours"], "week_regular_hours": week_overtime["regular_hours"], "week_overtime_hours": week_overtime["overtime_hours"], + "overtime_ytd_hours": overtime_ytd["overtime_hours"], + "overtime_ytd_regular": overtime_ytd["regular_hours"], "top_projects": top_projects, "time_by_project_7d": time_by_project_7d, "chart_labels_7d": chart_labels_7d, diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index 4e60bdb5..c9cfd2ba 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -188,6 +188,11 @@

{{ _('Time {{ "%.2f"|format(month_hours) }} hours + {% if overtime_ytd_hours is defined %} +
+ {{ _('Overtime (YTD)') }}: {{ "%.2f"|format(overtime_ytd_hours) }}h +
+ {% endif %}
From 251d41bc33469a6d6786adf921ffed1b1d98d2ab Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:39:20 +0100 Subject: [PATCH 06/20] feat(workforce): overtime overview and take as paid leave (Issue #560) - Workforce dashboard: show Accumulated overtime (YTD) next to Leave Balances - Add get_overtime_leave_type() and validate requested_hours <= YTD for overtime leave - Time-off form: 'Take as paid leave' link, overtime type preset, available hours hint - create_leave_request rejects overtime requests exceeding YTD with clear error --- app/routes/workforce.py | 18 ++++- app/services/workforce_governance_service.py | 22 +++++- app/templates/workforce/dashboard.html | 72 ++++++++++++++++++-- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/app/routes/workforce.py b/app/routes/workforce.py index 14fbf662..3772c892 100644 --- a/app/routes/workforce.py +++ b/app/routes/workforce.py @@ -1,4 +1,4 @@ -from datetime import datetime, date, timedelta +from datetime import datetime, date, timedelta from flask import Blueprint, render_template, request, redirect, url_for, flash, Response from flask_login import login_required, current_user @@ -71,12 +71,22 @@ def dashboard(): balances = service.get_leave_balance(selected_user_id) + from app.models import User + from app.utils.overtime import get_overtime_ytd + users = [] if current_user.is_admin: - from app.models import User - users = User.query.order_by(User.username.asc()).all() + # Accumulated overtime (YTD) for selected user and overtime leave type for "Take as paid leave" + selected_user = User.query.get(selected_user_id) + overtime_ytd_hours = 0.0 + overtime_leave_type = service.get_overtime_leave_type() + overtime_leave_type_id = overtime_leave_type.id if overtime_leave_type else None + if selected_user: + overtime_ytd = get_overtime_ytd(selected_user) + overtime_ytd_hours = float(overtime_ytd.get("overtime_hours", 0) or 0) + return render_template( "workforce/dashboard.html", periods=periods, @@ -91,6 +101,8 @@ def dashboard(): capacity=capacity, cap_start=cap_start, cap_end=cap_end, + overtime_ytd_hours=overtime_ytd_hours, + overtime_leave_type_id=overtime_leave_type_id, ) diff --git a/app/services/workforce_governance_service.py b/app/services/workforce_governance_service.py index 2d0e3f9d..cbff7244 100644 --- a/app/services/workforce_governance_service.py +++ b/app/services/workforce_governance_service.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from datetime import date, datetime, timedelta from decimal import Decimal @@ -179,6 +179,10 @@ def list_leave_types(self, enabled_only: bool = True) -> List[LeaveType]: q = q.filter(LeaveType.enabled.is_(True)) return q.order_by(LeaveType.name.asc()).all() + def get_overtime_leave_type(self) -> Optional[LeaveType]: + """Return the leave type used for overtime-as-paid-leave (code 'overtime'), if present.""" + return LeaveType.query.filter_by(code="overtime", enabled=True).first() + def create_leave_request( self, *, @@ -196,6 +200,22 @@ def create_leave_request( if end_date < start_date: return {"success": False, "message": "end_date must be after start_date"} + # When requesting overtime-as-leave, cap requested_hours at accumulated YTD overtime + if leave_type.code == "overtime" and requested_hours is not None and requested_hours > 0: + from app.utils.overtime import get_overtime_ytd + + user = User.query.get(user_id) + if user: + ytd = get_overtime_ytd(user) + ytd_overtime = float(ytd.get("overtime_hours", 0) or 0) + if float(requested_hours) > ytd_overtime: + return { + "success": False, + "message": f"Requested hours ({requested_hours}) exceed your accumulated overtime (YTD: {ytd_overtime:.2f}h). Please request at most {ytd_overtime:.2f} hours.", + } + else: + return {"success": False, "message": "User not found"} + status = TimeOffRequestStatus.SUBMITTED if submit_now else TimeOffRequestStatus.DRAFT req = TimeOffRequest( user_id=user_id, diff --git a/app/templates/workforce/dashboard.html b/app/templates/workforce/dashboard.html index a1e367c8..ac369d6c 100644 --- a/app/templates/workforce/dashboard.html +++ b/app/templates/workforce/dashboard.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "base.html" %} {% block title %}{{ _('Workforce Governance') }}{% endblock %} {% block content %} @@ -98,6 +98,14 @@

{{ _('Timesheet Periods') }}

{{ _('Leave Balances') }}

+ {% if overtime_ytd_hours is defined %} +

+ {{ _('Accumulated overtime (YTD)') }}: {{ "%.2f"|format(overtime_ytd_hours) }}h + {% if overtime_leave_type_id and overtime_ytd_hours > 0 %} + — {{ _('Take as paid leave') }} + {% endif %} +

+ {% endif %}
@@ -128,17 +136,22 @@

{{ _('Leave Balances') }}

{{ _('Time-Off Requests') }}

-
+ - {% for lt in leave_types %} {% if lt.enabled %} - + {% endif %} {% endfor %} - +
+ 0 %}data-overtime-max="{{ "%.2f"|format(overtime_ytd_hours) }}"{% endif %}> + {% if overtime_ytd_hours is defined and overtime_ytd_hours > 0 %} + + {% endif %} +
@@ -255,4 +268,53 @@

{{ _('Company Holidays') }}

{% endif %} +{% block scripts_extra %} + +{% endblock %} {% endblock %} From 60551f3720a50ab3d66dd22341ac55717a3d9dc0 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:39:28 +0100 Subject: [PATCH 07/20] feat(migration): seed Overtime leave type for take-as-paid-leave (Issue #560) - Migration 136: insert leave type code 'overtime' if not present - Enables workforce 'Take as paid leave' flow without manual admin setup --- .../versions/136_seed_overtime_leave_type.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 migrations/versions/136_seed_overtime_leave_type.py diff --git a/migrations/versions/136_seed_overtime_leave_type.py b/migrations/versions/136_seed_overtime_leave_type.py new file mode 100644 index 00000000..d6ee35f8 --- /dev/null +++ b/migrations/versions/136_seed_overtime_leave_type.py @@ -0,0 +1,50 @@ +"""Seed Overtime leave type for take-overtime-as-paid-leave (Issue #560) + +Revision ID: 136_seed_overtime_leave_type +Revises: 135_remind_to_log +Create Date: 2026-03-11 + +Creates a leave type with code 'overtime' if it does not exist, so users can +request time off from their accumulated overtime (YTD) as paid leave. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "136_seed_overtime_leave_type" +down_revision = "135_remind_to_log" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + if "leave_types" not in inspector.get_table_names(): + return + # Insert Overtime leave type only if not present (by code) + if bind.dialect.name == "sqlite": + op.execute( + sa.text(""" + INSERT INTO leave_types (name, code, is_paid, annual_allowance_hours, accrual_hours_per_month, enabled, created_at, updated_at) + SELECT 'Overtime', 'overtime', 1, NULL, NULL, 1, datetime('now'), datetime('now') + WHERE NOT EXISTS (SELECT 1 FROM leave_types WHERE code = 'overtime') + """) + ) + else: + op.execute( + sa.text(""" + INSERT INTO leave_types (name, code, is_paid, annual_allowance_hours, accrual_hours_per_month, enabled, created_at, updated_at) + SELECT 'Overtime', 'overtime', true, NULL, NULL, true, now(), now() + WHERE NOT EXISTS (SELECT 1 FROM leave_types WHERE code = 'overtime') + """) + ) + + +def downgrade(): + # Remove the seeded leave type (may break if time-off requests reference it) + bind = op.get_bind() + inspector = sa.inspect(bind) + if "leave_types" not in inspector.get_table_names(): + return + op.execute(sa.text("DELETE FROM leave_types WHERE code = 'overtime'")) From eefb529ef0afd6a14b10ef68b1933ecab3c7a14e Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:39:37 +0100 Subject: [PATCH 08/20] test(overtime): YTD helpers and overtime-as-leave validation (Issue #560) - TestOvertimeYTD: get_overtime_ytd / get_overtime_last_12_months structure and values - test_overtime_leave: request within YTD succeeds, exceeding YTD fails with validation - Smoke test: assert get_overtime_ytd is available on overtime module --- tests/test_overtime.py | 64 ++++++++++++++++++++++ tests/test_overtime_leave.py | 100 +++++++++++++++++++++++++++++++++++ tests/test_overtime_smoke.py | 1 + 3 files changed, 165 insertions(+) create mode 100644 tests/test_overtime_leave.py diff --git a/tests/test_overtime.py b/tests/test_overtime.py index 62ad5cf4..574cb60e 100644 --- a/tests/test_overtime.py +++ b/tests/test_overtime.py @@ -14,6 +14,8 @@ get_week_start_for_date, get_weekly_overtime_summary, get_overtime_statistics, + get_overtime_ytd, + get_overtime_last_12_months, ) @@ -501,3 +503,65 @@ def test_period_overtime_weekly_with_overtime(self, app, user_weekly, client_and assert result["regular_hours"] == 20.0 assert result["overtime_hours"] == 1.0 + +class TestOvertimeYTD: + """Tests for accumulated YTD and last-12-months overtime (Issue #560).""" + + @pytest.fixture + def ytd_user(self, app): + user = UserFactory() + user.standard_hours_per_day = 8.0 + db.session.add(user) + db.session.commit() + return user + + @pytest.fixture + def ytd_client_project(self, app): + client = ClientFactory(name="YTD Client") + db.session.commit() + project = ProjectFactory(client_id=client.id, name="YTD Project") + db.session.commit() + return client, project + + def test_get_overtime_ytd_returns_dict(self, app, ytd_user): + """get_overtime_ytd returns dict with expected keys.""" + result = get_overtime_ytd(ytd_user) + assert isinstance(result, dict) + assert "regular_hours" in result + assert "overtime_hours" in result + assert "total_hours" in result + assert "days_with_overtime" in result + + def test_get_overtime_ytd_no_entries(self, app, ytd_user): + """get_overtime_ytd with no entries returns zeros.""" + result = get_overtime_ytd(ytd_user) + assert result["total_hours"] == 0.0 + assert result["overtime_hours"] == 0.0 + assert result["regular_hours"] == 0.0 + + def test_get_overtime_ytd_with_entries(self, app, ytd_user, ytd_client_project): + """get_overtime_ytd includes YTD overtime from time entries.""" + _client, project = ytd_client_project + today = date.today() + # One day with 10h (2h overtime) + entry_start = datetime.combine(today, datetime.min.time().replace(hour=9)) + entry_end = entry_start + timedelta(hours=10) + TimeEntryFactory( + user_id=ytd_user.id, + project_id=project.id, + start_time=entry_start, + end_time=entry_end, + ) + db.session.commit() + result = get_overtime_ytd(ytd_user) + assert result["total_hours"] == 10.0 + assert result["regular_hours"] == 8.0 + assert result["overtime_hours"] == 2.0 + + def test_get_overtime_last_12_months_returns_dict(self, app, ytd_user): + """get_overtime_last_12_months returns dict with expected keys.""" + result = get_overtime_last_12_months(ytd_user) + assert isinstance(result, dict) + assert "overtime_hours" in result + assert "total_hours" in result + diff --git a/tests/test_overtime_leave.py b/tests/test_overtime_leave.py new file mode 100644 index 00000000..3df6a2ef --- /dev/null +++ b/tests/test_overtime_leave.py @@ -0,0 +1,100 @@ +""" +Tests for overtime-as-paid-leave flow (Issue #560). +- Leave type 'overtime' and create_leave_request validation (requested_hours <= YTD). +""" + +import pytest +from datetime import datetime, timedelta, date +from decimal import Decimal + +from app import db +from app.models import User, TimeEntry, Project, Client +from app.models.time_off import LeaveType, TimeOffRequest +from app.services.workforce_governance_service import WorkforceGovernanceService +from app.utils.overtime import get_overtime_ytd +from factories import UserFactory, ClientFactory, ProjectFactory, TimeEntryFactory + + +@pytest.fixture +def overtime_leave_type(app): + """Ensure an overtime leave type exists (code 'overtime').""" + lt = LeaveType.query.filter_by(code="overtime").first() + if not lt: + lt = LeaveType( + name="Overtime", + code="overtime", + is_paid=True, + annual_allowance_hours=None, + accrual_hours_per_month=None, + enabled=True, + ) + db.session.add(lt) + db.session.commit() + return lt + + +@pytest.fixture +def user_with_ytd_overtime(app, overtime_leave_type): + """User with 3 hours YTD overtime (one day 11h with 8h standard).""" + user = UserFactory() + user.standard_hours_per_day = 8.0 + db.session.add(user) + client = ClientFactory(name="OT Leave Client") + db.session.commit() + project = ProjectFactory(client_id=client.id, name="OT Leave Project") + db.session.commit() + today = date.today() + entry_start = datetime.combine(today, datetime.min.time().replace(hour=9)) + entry_end = entry_start + timedelta(hours=11) + TimeEntryFactory( + user_id=user.id, + project_id=project.id, + start_time=entry_start, + end_time=entry_end, + ) + db.session.commit() + return user + + +def test_overtime_leave_request_within_ytd_succeeds(app, user_with_ytd_overtime, overtime_leave_type): + """Requesting overtime leave with requested_hours <= YTD overtime succeeds.""" + user = user_with_ytd_overtime + ytd = get_overtime_ytd(user) + assert ytd["overtime_hours"] >= 3.0, "test user should have at least 3h YTD overtime" + service = WorkforceGovernanceService() + start = date.today() + timedelta(days=7) + end = start + timedelta(days=1) + result = service.create_leave_request( + user_id=user.id, + leave_type_id=overtime_leave_type.id, + start_date=start, + end_date=end, + requested_hours=Decimal("2.5"), + comment="Take 2.5h as leave", + submit_now=True, + ) + assert result["success"] is True + req = TimeOffRequest.query.filter_by(user_id=user.id, leave_type_id=overtime_leave_type.id).first() + assert req is not None + assert float(req.requested_hours) == 2.5 + + +def test_overtime_leave_request_exceeding_ytd_fails(app, user_with_ytd_overtime, overtime_leave_type): + """Requesting overtime leave with requested_hours > YTD overtime returns error.""" + user = user_with_ytd_overtime + ytd = get_overtime_ytd(user) + max_ytd = ytd["overtime_hours"] + service = WorkforceGovernanceService() + start = date.today() + timedelta(days=7) + end = start + timedelta(days=1) + result = service.create_leave_request( + user_id=user.id, + leave_type_id=overtime_leave_type.id, + start_date=start, + end_date=end, + requested_hours=Decimal(str(float(max_ytd) + 10.0)), + comment="Too many hours", + submit_now=True, + ) + assert result["success"] is False + assert "exceed" in result.get("message", "").lower() or "accumulated" in result.get("message", "").lower() diff --git a/tests/test_overtime_smoke.py b/tests/test_overtime_smoke.py index e59ca1fe..63bdaeb2 100644 --- a/tests/test_overtime_smoke.py +++ b/tests/test_overtime_smoke.py @@ -24,6 +24,7 @@ def test_overtime_utils_import(self): assert hasattr(overtime, "get_daily_breakdown") assert hasattr(overtime, "get_weekly_overtime_summary") assert hasattr(overtime, "get_overtime_statistics") + assert hasattr(overtime, "get_overtime_ytd") def test_user_model_has_standard_hours(self, app): """Smoke test: verify User model has standard_hours_per_day field""" From 1eadcd090ba06946c14a4c1b1988ab5860de0bcc Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:39:44 +0100 Subject: [PATCH 09/20] docs(overtime): accumulated YTD and take as paid leave (Issue #560) - Document accumulated overtime (YTD) and where it appears (dashboard, analytics, workforce) - Document take-overtime-as-paid-leave flow, API endpoints, and new helpers - Add migration 136 and test_overtime_leave.py to Testing section; bump version to 1.2.0 --- docs/features/OVERTIME_TRACKING.md | 34 ++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/features/OVERTIME_TRACKING.md b/docs/features/OVERTIME_TRACKING.md index afcc814a..f8622eb0 100644 --- a/docs/features/OVERTIME_TRACKING.md +++ b/docs/features/OVERTIME_TRACKING.md @@ -17,6 +17,13 @@ The Overtime Tracking feature allows users to track hours worked beyond their st - Navigate to Reports → User Report - Select your date range - View overtime breakdown in the report table + - **Accumulated overtime (YTD):** Your year-to-date overtime is shown on the main Dashboard (in the Month's Hours card), in Analytics (overtime API with `period=ytd`), and on the Workforce / Time-off page next to Leave Balances. + +3. **Take Overtime as Paid Leave (Issue #560)** + - Go to Workforce (or Time-off / Leave). + - Your **Accumulated overtime (YTD)** is displayed next to Leave Balances. + - Click **Take as paid leave** to scroll to the time-off request form with the "Overtime" leave type selected. + - Enter the number of hours to request (capped at your YTD overtime); submit the request as usual. Approved requests record the hours taken as leave (no automatic balance deduction in v1). ### For Developers @@ -28,10 +35,10 @@ The Overtime Tracking feature allows users to track hours worked beyond their st - `migrations/versions/031_add_standard_hours_per_day.py` - Database migration (daily) - `migrations/versions/134_add_overtime_weekly_mode.py` - Weekly mode (Issue #551) -**API Endpoint:** -``` -GET /api/analytics/overtime?days=30 -``` +**API Endpoints:** +- `GET /api/analytics/overtime?days=30` — Overtime for the last N days. +- `GET /api/analytics/overtime?period=ytd` — Year-to-date accumulated overtime. +- `GET /api/dashboard/stats` and dashboard stats APIs include `overtime_ytd_hours`. **Key Functions:** ```python @@ -41,15 +48,17 @@ from app.utils.overtime import ( get_daily_breakdown, get_week_start_for_date, get_weekly_overtime_summary, - get_overtime_statistics + get_overtime_statistics, + get_overtime_ytd, # YTD accumulated overtime + get_overtime_last_12_months # Optional: last 12 months ) ``` ### Testing ```bash -# Run all overtime tests -pytest tests/test_overtime.py tests/test_overtime_smoke.py -v +# Run all overtime tests (including YTD and overtime-as-leave) +pytest tests/test_overtime.py tests/test_overtime_smoke.py tests/test_overtime_leave.py -v # With coverage pytest tests/test_overtime*.py --cov=app.utils.overtime --cov-report=html @@ -100,14 +109,16 @@ pytest tests/test_overtime*.py --cov=app.utils.overtime --cov-report=html - `overtime_calculation_mode`: `VARCHAR(10)`, default `'daily'`, NOT NULL - `standard_hours_per_week`: `FLOAT`, nullable -**Migrations:** `031_add_standard_hours_per_day`, `134_add_overtime_weekly_mode` +**Migrations:** `031_add_standard_hours_per_day`, `134_add_overtime_weekly_mode`, `136_seed_overtime_leave_type` (seeds "Overtime" leave type for take-as-paid-leave) ## Features ✅ User-configurable standard hours ✅ Automatic overtime calculation ✅ Display in user reports -✅ Analytics API endpoint +✅ Analytics API endpoint (including `period=ytd`) +✅ **Accumulated overtime (YTD)** on Dashboard, Analytics, and Workforce +✅ **Take overtime as paid leave** — Overtime leave type and request flow (Issue #560) ✅ Daily overtime breakdown ✅ Weekly overtime summaries ✅ Comprehensive statistics @@ -117,6 +128,7 @@ pytest tests/test_overtime*.py --cov=app.utils.overtime --cov-report=html ## Future Enhancements - Weekly overtime thresholds (implemented as optional weekly mode; see Issue #551) +- Stored overtime balance and deduction when overtime leave is approved (v1 records only) - Overtime approval workflows - Overtime pay rate calculations - Email notifications for excessive overtime @@ -132,7 +144,7 @@ For questions or issues: --- -**Version:** 1.1.0 +**Version:** 1.2.0 **Status:** ✅ Production Ready -**Last Updated:** March 9, 2026 +**Last Updated:** March 11, 2026 From f66d5b7547b4f6da5c7eafdc08a8facadcbc9f70 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:57:52 +0100 Subject: [PATCH 10/20] feat(break-time): add migrations for break_seconds, paused_at, and default break rules (Issue #561) - Migration 137: add time_entries.break_seconds, time_entries.paused_at - Migration 138: add settings break_after_hours_1/2, break_minutes_1/2 (e.g. 6h->30min, 9h->45min) --- .../137_add_break_time_to_time_entries.py | 51 +++++++++++++++++++ .../138_add_default_break_rules_settings.py | 48 +++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 migrations/versions/137_add_break_time_to_time_entries.py create mode 100644 migrations/versions/138_add_default_break_rules_settings.py diff --git a/migrations/versions/137_add_break_time_to_time_entries.py b/migrations/versions/137_add_break_time_to_time_entries.py new file mode 100644 index 00000000..fdde3130 --- /dev/null +++ b/migrations/versions/137_add_break_time_to_time_entries.py @@ -0,0 +1,51 @@ +"""Add break_seconds and paused_at to time_entries (Issue #561) + +Revision ID: 137_add_break_time +Revises: 136_seed_overtime_leave_type +Create Date: 2026-03-11 + +Break time for timers (pause/resume) and manual time entries. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "137_add_break_time" +down_revision = "136_seed_overtime_leave_type" +branch_labels = None +depends_on = None + + +def upgrade(): + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + if "time_entries" not in inspector.get_table_names(): + return + columns = {c["name"] for c in inspector.get_columns("time_entries")} + + if "break_seconds" not in columns: + op.add_column( + "time_entries", + sa.Column("break_seconds", sa.Integer(), nullable=True, server_default="0"), + ) + if "paused_at" not in columns: + op.add_column( + "time_entries", + sa.Column("paused_at", sa.DateTime(), nullable=True), + ) + + +def downgrade(): + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + if "time_entries" not in inspector.get_table_names(): + return + columns = {c["name"] for c in inspector.get_columns("time_entries")} + if "paused_at" in columns: + op.drop_column("time_entries", "paused_at") + if "break_seconds" in columns: + op.drop_column("time_entries", "break_seconds") diff --git a/migrations/versions/138_add_default_break_rules_settings.py b/migrations/versions/138_add_default_break_rules_settings.py new file mode 100644 index 00000000..0e4d4bcd --- /dev/null +++ b/migrations/versions/138_add_default_break_rules_settings.py @@ -0,0 +1,48 @@ +"""Add default break rules to settings (Issue #561) + +Revision ID: 138_add_break_rules +Revises: 137_add_break_time +Create Date: 2026-03-11 + +Optional default break rules (e.g. Germany: >6h = 30 min, >9h = 45 min). +""" +from alembic import op +import sqlalchemy as sa + + +revision = "138_add_break_rules" +down_revision = "137_add_break_time" +branch_labels = None +depends_on = None + + +def upgrade(): + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + if "settings" not in inspector.get_table_names(): + return + columns = {c["name"] for c in inspector.get_columns("settings")} + + if "break_after_hours_1" not in columns: + op.add_column("settings", sa.Column("break_after_hours_1", sa.Float(), nullable=True, server_default="6")) + if "break_minutes_1" not in columns: + op.add_column("settings", sa.Column("break_minutes_1", sa.Integer(), nullable=True, server_default="30")) + if "break_after_hours_2" not in columns: + op.add_column("settings", sa.Column("break_after_hours_2", sa.Float(), nullable=True, server_default="9")) + if "break_minutes_2" not in columns: + op.add_column("settings", sa.Column("break_minutes_2", sa.Integer(), nullable=True, server_default="45")) + + +def downgrade(): + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + if "settings" not in inspector.get_table_names(): + return + columns = {c["name"] for c in inspector.get_columns("settings")} + for name in ["break_after_hours_2", "break_minutes_2", "break_after_hours_1", "break_minutes_1"]: + if name in columns: + op.drop_column("settings", name) From 8752d3d6aaa6f6e24e65b93cb7ac0ede5bfbd0c7 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:58:37 +0100 Subject: [PATCH 11/20] feat(break-time): add break_seconds and pause support to TimeEntry and schemas (Issue #561) - TimeEntry: break_seconds, paused_at; pause_timer(), resume_timer(); current_duration_seconds and calculate_duration() account for break - Settings: break_after_hours_1/2, break_minutes_1/2 for default break rules - Repository: create_manual_entry accepts break_seconds - Schemas: break_seconds on TimeEntrySchema, Create, Update --- app/models/settings.py | 6 ++ app/models/time_entry.py | 68 +++++++++++++++++++---- app/repositories/time_entry_repository.py | 6 +- app/schemas/time_entry_schema.py | 3 + 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/app/models/settings.py b/app/models/settings.py index c4a95cdd..8126aba1 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -118,6 +118,12 @@ class Settings(db.Model): # Overtime / time tracking: default daily working hours for new users (e.g. 8.0) default_daily_working_hours = db.Column(db.Float, default=8.0, nullable=False) + # Default break rules for time entries (e.g. Germany: >6h = 30 min, >9h = 45 min). User can override per entry. + break_after_hours_1 = db.Column(db.Float, nullable=True) # e.g. 6 + break_minutes_1 = db.Column(db.Integer, nullable=True) # e.g. 30 + break_after_hours_2 = db.Column(db.Float, nullable=True) # e.g. 9 + break_minutes_2 = db.Column(db.Integer, nullable=True) # e.g. 45 + # Email configuration settings (stored in database, takes precedence over environment variables) mail_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable database-backed email config mail_server = db.Column(db.String(255), default="", nullable=True) diff --git a/app/models/time_entry.py b/app/models/time_entry.py index 93c8f778..1fce9d6d 100644 --- a/app/models/time_entry.py +++ b/app/models/time_entry.py @@ -26,6 +26,8 @@ class TimeEntry(db.Model): start_time = db.Column(db.DateTime, nullable=False, index=True) end_time = db.Column(db.DateTime, nullable=True, index=True) duration_seconds = db.Column(db.Integer, nullable=True) + break_seconds = db.Column(db.Integer, nullable=True, default=0) + paused_at = db.Column(db.DateTime, nullable=True) notes = db.Column(db.Text, nullable=True) tags = db.Column(db.String(500), nullable=True) # Comma-separated tags source = db.Column(db.String(20), default="manual", nullable=False) # 'manual' or 'auto' @@ -55,6 +57,7 @@ def __init__( paid=False, invoice_number=None, duration_seconds=None, + break_seconds=None, **kwargs, ): """Initialize a TimeEntry instance. @@ -73,8 +76,11 @@ def __init__( paid: Whether this entry has been paid invoice_number: Optional internal invoice number reference duration_seconds: Optional duration override (usually calculated automatically) + break_seconds: Optional break time in seconds to subtract from duration **kwargs: Additional keyword arguments (for SQLAlchemy compatibility) """ + if break_seconds is not None: + self.break_seconds = int(break_seconds) if user_id is not None: self.user_id = user_id if project_id is not None: @@ -126,6 +132,20 @@ def is_active(self): """Check if this is an active timer (no end time)""" return self.end_time is None + @property + def is_paused(self): + """Check if this active timer is currently paused""" + return self.paused_at is not None + + @property + def break_formatted(self): + """Format break_seconds as HH:MM:SS for display""" + sec = self.break_seconds or 0 + hours = sec // 3600 + minutes = (sec % 3600) // 60 + seconds = sec % 60 + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + @property def duration_hours(self): """Get duration in hours""" @@ -160,20 +180,19 @@ def tag_list(self): @property def current_duration_seconds(self): - """Calculate current duration for active timers""" + """Calculate current duration for active timers (worked time, excluding break).""" if self.end_time: return self.duration_seconds or 0 - # For active timers, calculate from start time to now - # Since we store everything in local timezone, we can work with naive datetimes - # as long as we treat them as local time - - # Get current time in local timezone (naive, matching database storage) + # For active timers: elapsed time minus break; if paused, time stops at paused_at + break_sec = self.break_seconds or 0 now_local = local_now() - - # Calculate duration (both times are treated as local time) - duration = now_local - self.start_time - return int(duration.total_seconds()) + end_ref = self._naive_dt(self.paused_at) if self.paused_at else now_local + start_naive = self._naive_dt(self.start_time) + if start_naive is None or end_ref is None: + return 0 + raw_seconds = int((end_ref - start_naive).total_seconds()) + return max(0, raw_seconds - break_sec) def _naive_dt(self, dt): """Return datetime as naive local for duration math (handles DB returning aware in some backends).""" @@ -197,6 +216,8 @@ def calculate_duration(self): return duration = end - start raw_seconds = int(duration.total_seconds()) + break_sec = self.break_seconds or 0 + raw_seconds = max(0, raw_seconds - break_sec) # Apply per-user rounding if user preferences are set if self.user and hasattr(self.user, "time_rounding_enabled"): @@ -230,6 +251,31 @@ def stop_timer(self, end_time=None): db.session.commit() + def pause_timer(self): + """Pause an active timer (clock stops; break accumulates on resume).""" + if self.end_time: + raise ValueError("Timer is already stopped") + if self.paused_at: + raise ValueError("Timer is already paused") + self.paused_at = local_now() + self.updated_at = local_now() + db.session.commit() + + def resume_timer(self): + """Resume a paused timer (accumulate time since paused_at into break_seconds).""" + if self.end_time: + raise ValueError("Timer is already stopped") + if not self.paused_at: + raise ValueError("Timer is not paused") + now = local_now() + paused_naive = self._naive_dt(self.paused_at) + if paused_naive: + added_break = int((now - paused_naive).total_seconds()) + self.break_seconds = (self.break_seconds or 0) + added_break + self.paused_at = None + self.updated_at = local_now() + db.session.commit() + def update_notes(self, notes): """Update notes for this entry""" self.notes = notes.strip() if notes else None @@ -270,6 +316,8 @@ def to_dict(self): "start_time": self.start_time.isoformat() if self.start_time else None, "end_time": self.end_time.isoformat() if self.end_time else None, "duration_seconds": self.duration_seconds, + "break_seconds": self.break_seconds, + "paused_at": self.paused_at.isoformat() if self.paused_at else None, "duration_hours": self.duration_hours, "duration_formatted": self.duration_formatted, "notes": self.notes, diff --git a/app/repositories/time_entry_repository.py b/app/repositories/time_entry_repository.py index 5236a68e..d72daad3 100644 --- a/app/repositories/time_entry_repository.py +++ b/app/repositories/time_entry_repository.py @@ -164,6 +164,7 @@ def create_manual_entry( start_time: datetime = None, end_time: datetime = None, duration_seconds: Optional[int] = None, + break_seconds: Optional[int] = None, task_id: Optional[int] = None, notes: Optional[str] = None, tags: Optional[str] = None, @@ -171,7 +172,7 @@ def create_manual_entry( paid: bool = False, invoice_number: Optional[str] = None, ) -> TimeEntry: - """Create a manual time entry""" + """Create a manual time entry. duration_seconds is net (worked time); break_seconds is subtracted when computing from start/end.""" entry = self.model( user_id=user_id, project_id=project_id, @@ -180,6 +181,7 @@ def create_manual_entry( start_time=start_time, end_time=end_time, duration_seconds=duration_seconds, + break_seconds=break_seconds or 0, notes=notes, tags=tags, billable=billable, @@ -187,8 +189,6 @@ def create_manual_entry( invoice_number=invoice_number, source=TimeEntrySource.MANUAL.value, ) - # If duration_seconds is explicitly provided, `TimeEntry.__init__` will keep it. - # Still run calculate_duration() to apply rounding behavior when duration was not provided. if duration_seconds is None: entry.calculate_duration() db.session.add(entry) diff --git a/app/schemas/time_entry_schema.py b/app/schemas/time_entry_schema.py index e2a9a811..563358de 100644 --- a/app/schemas/time_entry_schema.py +++ b/app/schemas/time_entry_schema.py @@ -18,6 +18,7 @@ class TimeEntrySchema(Schema): start_time = fields.DateTime(required=True) end_time = fields.DateTime(allow_none=True) duration_seconds = fields.Int(allow_none=True) + break_seconds = fields.Int(allow_none=True) notes = fields.Str(allow_none=True) tags = fields.Str(allow_none=True) source = fields.Str(validate=validate.OneOf([s.value for s in TimeEntrySource])) @@ -42,6 +43,7 @@ class TimeEntryCreateSchema(Schema): task_id = fields.Int(allow_none=True) start_time = fields.DateTime(required=True) end_time = fields.DateTime(allow_none=True) + break_seconds = fields.Int(allow_none=True) notes = fields.Str(allow_none=True, validate=validate.Length(max=5000)) tags = fields.Str(allow_none=True, validate=validate.Length(max=500)) billable = fields.Bool(missing=True) @@ -93,6 +95,7 @@ class TimeEntryUpdateSchema(Schema): task_id = fields.Int(allow_none=True) start_time = fields.DateTime(allow_none=True) end_time = fields.DateTime(allow_none=True) + break_seconds = fields.Int(allow_none=True) notes = fields.Str(allow_none=True, validate=validate.Length(max=5000)) tags = fields.Str(allow_none=True, validate=validate.Length(max=500)) billable = fields.Bool(allow_none=True) From 7813f3f76a3f017c3ec454d3ea394b524e809602 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:58:46 +0100 Subject: [PATCH 12/20] feat(break-time): add pause_timer/resume_timer and break_seconds to service (Issue #561) - pause_timer(user_id), resume_timer(user_id) in TimeTrackingService - create_manual_entry and update_entry accept break_seconds; duration = (end-start)-break --- app/services/time_tracking_service.py | 43 +++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/app/services/time_tracking_service.py b/app/services/time_tracking_service.py index 8be46c26..001d7f55 100644 --- a/app/services/time_tracking_service.py +++ b/app/services/time_tracking_service.py @@ -191,6 +191,36 @@ def stop_timer(self, user_id: int, entry_id: Optional[int] = None) -> Dict[str, return {"success": True, "message": "Timer stopped successfully", "entry": entry} + def pause_timer(self, user_id: int) -> Dict[str, Any]: + """Pause the active timer for a user. Clock stops; break accumulates on resume.""" + entry = self.time_entry_repo.get_active_timer(user_id) + if not entry: + return {"success": False, "message": "No active timer found", "error": "no_active_timer"} + if entry.user_id != user_id: + return {"success": False, "message": "You can only pause your own timer", "error": "unauthorized"} + try: + entry.pause_timer() + except ValueError as e: + return {"success": False, "message": str(e), "error": "invalid_state"} + if not safe_commit("pause_timer", {"user_id": user_id, "entry_id": entry.id}): + return {"success": False, "message": "Could not pause timer", "error": "database_error"} + return {"success": True, "message": "Timer paused", "entry": entry} + + def resume_timer(self, user_id: int) -> Dict[str, Any]: + """Resume a paused timer; time since pause is added to break_seconds.""" + entry = self.time_entry_repo.get_active_timer(user_id) + if not entry: + return {"success": False, "message": "No active timer found", "error": "no_active_timer"} + if entry.user_id != user_id: + return {"success": False, "message": "You can only resume your own timer", "error": "unauthorized"} + try: + entry.resume_timer() + except ValueError as e: + return {"success": False, "message": str(e), "error": "invalid_state"} + if not safe_commit("resume_timer", {"user_id": user_id, "entry_id": entry.id}): + return {"success": False, "message": "Could not resume timer", "error": "database_error"} + return {"success": True, "message": "Timer resumed", "entry": entry} + def create_manual_entry( self, user_id: int, @@ -199,6 +229,7 @@ def create_manual_entry( start_time: datetime = None, end_time: datetime = None, duration_seconds: Optional[int] = None, + break_seconds: Optional[int] = None, task_id: Optional[int] = None, notes: Optional[str] = None, tags: Optional[str] = None, @@ -293,7 +324,9 @@ def create_manual_entry( if duration_seconds <= 0: return {"success": False, "message": "Duration must be positive", "error": "invalid_duration"} - # Create entry + # Create entry (duration_seconds is net; break_seconds is stored and subtracted when computing from start/end) + if break_seconds is not None: + break_seconds = max(0, int(break_seconds)) entry = self.time_entry_repo.create_manual_entry( user_id=user_id, project_id=project_id, @@ -301,6 +334,7 @@ def create_manual_entry( start_time=start_time, end_time=end_time, duration_seconds=duration_seconds, + break_seconds=break_seconds, task_id=task_id, notes=notes, tags=tags, @@ -365,6 +399,7 @@ def update_entry( task_id: Optional[int] = None, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, + break_seconds: Optional[int] = None, notes: Optional[str] = None, tags: Optional[str] = None, billable: Optional[bool] = None, @@ -460,8 +495,10 @@ def update_entry( entry.start_time = start_time if end_time is not None: entry.end_time = end_time - # Recompute stored duration when start or end time changed - if entry.end_time and (start_time is not None or end_time is not None): + if break_seconds is not None: + entry.break_seconds = max(0, int(break_seconds)) + # Recompute stored duration when start, end, or break changed + if entry.end_time and (start_time is not None or end_time is not None or break_seconds is not None): entry.calculate_duration() if notes is not None: entry.notes = notes From cef83ff51d66302109334844d02665a48310f747 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:58:56 +0100 Subject: [PATCH 13/20] feat(break-time): add pause/resume routes, timer status, manual and edit break (Issue #561) - Web: POST /timer/pause, POST /timer/resume; timer_status returns paused, break_seconds, break_formatted - API v1: POST /api/v1/timer/pause, POST /api/v1/timer/resume - manual_entry: parse break_time (HH:MM), pass break_seconds; prefill on duplicate - edit_timer: parse break_time, pass break_seconds to update_entry; recalc duration - API v1 time entry create/update accept break_seconds --- app/routes/api_v1_time_entries.py | 34 ++++++++++++ app/routes/timer.py | 86 +++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/app/routes/api_v1_time_entries.py b/app/routes/api_v1_time_entries.py index 0ad3d0f3..43c8ff09 100644 --- a/app/routes/api_v1_time_entries.py +++ b/app/routes/api_v1_time_entries.py @@ -103,6 +103,7 @@ def create_time_entry(): client_id=validated.get("client_id"), start_time=start_time, end_time=end_time, + break_seconds=validated.get("break_seconds"), task_id=validated.get("task_id"), notes=validated.get("notes"), tags=validated.get("tags"), @@ -169,6 +170,7 @@ def update_time_entry(entry_id): task_id=validated.get("task_id"), start_time=validated.get("start_time"), end_time=validated.get("end_time"), + break_seconds=validated.get("break_seconds"), notes=validated.get("notes"), tags=validated.get("tags"), billable=validated.get("billable"), @@ -266,6 +268,38 @@ def start_timer(): return jsonify({"message": "Timer started successfully", "timer": result["timer"].to_dict()}), 201 +@api_v1_time_entries_bp.route("/timer/pause", methods=["POST"]) +@require_api_token("write:time_entries") +def pause_timer(): + """Pause the active timer (clock stops; break accumulates on resume).""" + from app.services import TimeTrackingService + + time_tracking_service = TimeTrackingService() + result = time_tracking_service.pause_timer(user_id=g.api_user.id) + if not result.get("success"): + return jsonify({ + "error": result.get("message", "Could not pause timer"), + "error_code": result.get("error", "pause_failed"), + }), 400 + return jsonify({"message": "Timer paused", "time_entry": result["entry"].to_dict()}) + + +@api_v1_time_entries_bp.route("/timer/resume", methods=["POST"]) +@require_api_token("write:time_entries") +def resume_timer(): + """Resume a paused timer (time since pause is counted as break).""" + from app.services import TimeTrackingService + + time_tracking_service = TimeTrackingService() + result = time_tracking_service.resume_timer(user_id=g.api_user.id) + if not result.get("success"): + return jsonify({ + "error": result.get("message", "Could not resume timer"), + "error_code": result.get("error", "resume_failed"), + }), 400 + return jsonify({"message": "Timer resumed", "time_entry": result["entry"].to_dict()}) + + @api_v1_time_entries_bp.route("/timer/stop", methods=["POST"]) @require_api_token("write:time_entries") def stop_timer(): diff --git a/app/routes/timer.py b/app/routes/timer.py index ab8a28ac..d7ea3085 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -567,6 +567,56 @@ def stop_timer(): return redirect(url_for("main.dashboard")) +@timer_bp.route("/timer/pause", methods=["POST"]) +@login_required +def pause_timer(): + """Pause the current user's active timer (clock stops; break accumulates on resume).""" + active_timer = current_user.active_timer + if not active_timer: + flash(_("No active timer to pause"), "error") + return redirect(url_for("main.dashboard")) + try: + active_timer.pause_timer() + flash(_("Timer paused"), "success") + except ValueError as e: + flash(_(str(e)), "error") + except Exception as e: + current_app.logger.exception("Error pausing timer: %s", e) + flash(_("Could not pause timer. Please try again."), "error") + try: + from app.utils.cache import get_cache + cache = get_cache() + cache.delete(f"dashboard:{current_user.id}") + except Exception: + pass + return redirect(url_for("main.dashboard")) + + +@timer_bp.route("/timer/resume", methods=["POST"]) +@login_required +def resume_timer(): + """Resume a paused timer (time since pause is counted as break).""" + active_timer = current_user.active_timer + if not active_timer: + flash(_("No active timer to resume"), "error") + return redirect(url_for("main.dashboard")) + try: + active_timer.resume_timer() + flash(_("Timer resumed"), "success") + except ValueError as e: + flash(_(str(e)), "error") + except Exception as e: + current_app.logger.exception("Error resuming timer: %s", e) + flash(_("Could not resume timer. Please try again."), "error") + try: + from app.utils.cache import get_cache + cache = get_cache() + cache.delete(f"dashboard:{current_user.id}") + except Exception: + pass + return redirect(url_for("main.dashboard")) + + @timer_bp.route("/timer/adjust", methods=["POST"]) @login_required def adjust_timer(): @@ -629,6 +679,10 @@ def timer_status(): "start_time": active_timer.start_time.isoformat(), "current_duration": active_timer.current_duration_seconds, "duration_formatted": active_timer.duration_formatted, + "paused": getattr(active_timer, "is_paused", False), + "paused_at": active_timer.paused_at.isoformat() if active_timer.paused_at else None, + "break_seconds": getattr(active_timer, "break_seconds", None) or 0, + "break_formatted": getattr(active_timer, "break_formatted", "00:00:00"), }, } ) @@ -724,6 +778,7 @@ def edit_timer(timer_id): start_time = request.form.get("start_time") end_date = request.form.get("end_date") end_time = request.form.get("end_time") + break_time = (request.form.get("break_time") or "").strip() if start_date and start_time: try: @@ -785,6 +840,14 @@ def edit_timer(timer_id): else: update_params["end_time"] = None + # Parse break time (HH:MM) to seconds; empty clears break + import re + if break_time: + m = re.match(r"^(\d{1,3}):([0-5]\d)$", break_time.strip()) + update_params["break_seconds"] = (int(m.group(1)) * 3600 + int(m.group(2)) * 60) if m else 0 + else: + update_params["break_seconds"] = 0 + # Call service layer to update result = service.update_entry(**update_params) @@ -1174,6 +1237,7 @@ def manual_entry(): end_time = request.form.get("end_time") worked_time = (request.form.get("worked_time") or "").strip() worked_time_mode = (request.form.get("worked_time_mode") or "").strip() # 'explicit' when user typed duration + break_time = (request.form.get("break_time") or "").strip() notes = sanitize_input(request.form.get("notes", "").strip(), max_length=2000) tags = sanitize_input(request.form.get("tags", "").strip(), max_length=500) billable = request.form.get("billable") == "on" @@ -1192,6 +1256,8 @@ def _parse_worked_time_minutes(raw: str): return total if total > 0 else None worked_minutes = _parse_worked_time_minutes(worked_time) + break_minutes = _parse_worked_time_minutes(break_time) + break_seconds = (break_minutes * 60) if break_minutes is not None else None has_all_times = bool(start_date and start_time and end_date and end_time) has_duration = worked_minutes is not None @@ -1218,6 +1284,7 @@ def _parse_worked_time_minutes(raw: str): prefill_end_time=end_time, prefill_worked_time=worked_time, prefill_worked_time_mode=worked_time_mode, + prefill_break_time=break_time, ) # Validate that either project or client is selected @@ -1240,6 +1307,9 @@ def _parse_worked_time_minutes(raw: str): prefill_start_time=start_time, prefill_end_date=end_date, prefill_end_time=end_time, + prefill_worked_time=worked_time, + prefill_worked_time_mode=worked_time_mode, + prefill_break_time=break_time, ) # If a locked client is configured, ensure selected project matches it. @@ -1255,6 +1325,7 @@ def _parse_worked_time_minutes(raw: str): # Parse datetime: treat form input as user's local time, store in app timezone. # If duration + start date/time are provided: end = start + duration. # If duration only (no start): end=now, start=end-duration. + # Break is subtracted from span to get worked duration. from datetime import timedelta try: if has_all_times: @@ -1262,6 +1333,8 @@ def _parse_worked_time_minutes(raw: str): end_time_parsed = parse_user_local_datetime(end_date, end_time, current_user) if worked_time_mode == "explicit" and has_duration: duration_seconds_override = worked_minutes * 60 + # When we have start/end and break, we pass break_seconds and do not override duration; + # calculate_duration() will compute (end - start) - break_seconds elif has_duration and start_date and start_time: # Combined: worked time + start date/time (user can set date and duration) start_time_parsed = parse_user_local_datetime(start_date, start_time, current_user) @@ -1294,8 +1367,13 @@ def _parse_worked_time_minutes(raw: str): prefill_end_time=end_time, prefill_worked_time=worked_time, prefill_worked_time_mode=worked_time_mode, + prefill_break_time=break_time, ) + # When user entered both duration override and break, net duration = duration - break + if duration_seconds_override is not None and break_seconds is not None: + duration_seconds_override = max(0, duration_seconds_override - break_seconds) + # Validate time range if end_time_parsed <= start_time_parsed: flash(_("End time must be after start time"), "error") @@ -1316,6 +1394,9 @@ def _parse_worked_time_minutes(raw: str): prefill_start_time=start_time, prefill_end_date=end_date, prefill_end_time=end_time, + prefill_worked_time=worked_time, + prefill_worked_time_mode=worked_time_mode, + prefill_break_time=break_time, ) # Use service to create entry (handles validation) @@ -1327,6 +1408,7 @@ def _parse_worked_time_minutes(raw: str): start_time=start_time_parsed, end_time=end_time_parsed, duration_seconds=duration_seconds_override, + break_seconds=break_seconds, task_id=task_id, notes=notes if notes else None, tags=tags if tags else None, @@ -1354,6 +1436,7 @@ def _parse_worked_time_minutes(raw: str): prefill_end_time=end_time, prefill_worked_time=worked_time, prefill_worked_time_mode=worked_time_mode, + prefill_break_time=break_time, ) entry = result.get("entry") @@ -1855,6 +1938,8 @@ def duplicate_timer(timer_id): ) # Render the manual entry form with pre-filled data + break_sec = getattr(timer, "break_seconds", None) or 0 + prefill_break = f"{break_sec // 3600}:{(break_sec % 3600) // 60:02d}" if break_sec else "" return render_template( "timer/manual_entry.html", projects=active_projects, @@ -1867,6 +1952,7 @@ def duplicate_timer(timer_id): prefill_notes=timer.notes, prefill_tags=timer.tags, prefill_billable=timer.billable, + prefill_break_time=prefill_break, is_duplicate=True, original_entry=timer, ) From c36736d0638607fbc6042176551c8e56228dd84d Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:59:03 +0100 Subject: [PATCH 14/20] feat(break-time): add Pause/Resume and break UI (Issue #561) - Dashboard: Pause/Resume buttons, break and Paused badge, elapsed uses break-adjusted duration - Timer page: Pause/Resume/Stop, break display - Floating bar: paused state, Resume on click when paused; use server current_duration when paused - Manual entry: Break field (HH:MM), Suggest button using default break rules - Edit time entry: Break field (HH:MM) for admins --- app/static/floating-timer-bar.js | 62 +++++++++++++++++++++------ app/templates/main/dashboard.html | 29 +++++++++++++ app/templates/timer/edit_timer.html | 8 ++++ app/templates/timer/manual_entry.html | 40 ++++++++++++++++- app/templates/timer/timer_page.html | 20 ++++++++- 5 files changed, 145 insertions(+), 14 deletions(-) diff --git a/app/static/floating-timer-bar.js b/app/static/floating-timer-bar.js index d1270844..2c6c0706 100644 --- a/app/static/floating-timer-bar.js +++ b/app/static/floating-timer-bar.js @@ -53,16 +53,24 @@ startElapsedUpdater() { this.stopElapsedUpdater(); const update = () => { - if (!this.startTime || !this.bar) return; - const elapsed = Math.floor((Date.now() - this.startTime) / 1000); - const h = Math.floor(elapsed / 3600); - const m = Math.floor((elapsed % 3600) / 60); - const s = elapsed % 60; + if (!this.timerData || !this.bar) return; + let elapsedSec; + if (this.timerData.paused) { + elapsedSec = this.timerData.current_duration || 0; + } else { + elapsedSec = this.timerData.current_duration != null + ? this.timerData.current_duration + : (this.startTime ? Math.floor((Date.now() - this.startTime) / 1000) : 0); + } + const h = Math.floor(elapsedSec / 3600); + const m = Math.floor((elapsedSec % 3600) / 60); + const s = elapsedSec % 60; const formatted = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); const el = this.bar.querySelector('[data-timer-elapsed]'); if (el) el.textContent = formatted; const btn = this.bar.querySelector('button'); - if (btn) btn.title = (this.getLabel() || 'Timer') + ' – ' + formatted + ' – ' + (this.stopLabel || 'Stop'); + const label = this.timerData.paused ? (this.bar.dataset.resumeLabel || 'Resume') : (this.stopLabel || 'Stop'); + if (btn) btn.title = (this.getLabel() || 'Timer') + (this.timerData.paused ? ' (Paused) – ' : ' – ') + formatted + ' – ' + label; }; update(); this.elapsedInterval = setInterval(update, 1000); @@ -86,8 +94,7 @@ } async stopTimer() { - const tokenEl = document.querySelector('meta[name="csrf-token"]'); - const token = tokenEl ? tokenEl.getAttribute('content') : ''; + const token = this.getCsrfToken(); try { const res = await fetch('/timer/stop', { method: 'POST', @@ -108,6 +115,33 @@ } } + async resumeTimer() { + const token = this.getCsrfToken(); + try { + const res = await fetch('/timer/resume', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': token }, + body: 'csrf_token=' + encodeURIComponent(token), + credentials: 'same-origin' + }); + if (res.redirected) { + window.location.href = res.url; + } else { + await this.fetchStatus(); + } + } catch (e) { + console.error('Resume timer failed', e); + if (window.toastManager) { + window.toastManager.error('Failed to resume timer', 'Error', 3000); + } + } + } + + getCsrfToken() { + const tokenEl = document.querySelector('meta[name="csrf-token"]'); + return tokenEl ? tokenEl.getAttribute('content') || '' : ''; + } + getLabel() { if (!this.timerData) return ''; return this.timerData.project_name || this.timerData.client_name || 'Timer'; @@ -117,15 +151,19 @@ if (!this.bar) return; const baseClass = 'floating-timer-bar__round flex items-center justify-center w-10 h-10 rounded-full text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 text-sm transition-colors'; + const actionLabel = this.timerData && this.timerData.paused ? (this.bar.dataset.resumeLabel || 'Resume') : (this.stopLabel || 'Stop'); const title = this.timerData - ? (escapeHtml(this.getLabel()) + ' – ' + (this.timerData.duration_formatted || '00:00:00') + ' – ' + escapeHtml(this.stopLabel)) + ? (escapeHtml(this.getLabel()) + (this.timerData.paused ? ' (Paused) – ' : ' – ') + (this.timerData.duration_formatted || '00:00:00') + ' – ' + escapeHtml(actionLabel)) : escapeHtml(this.startLabel); if (this.timerData) { + const isPaused = this.timerData.paused; + const pulseClass = isPaused ? 'bg-amber-500' : 'bg-green-500 animate-pulse'; + const clickHandler = isPaused ? 'window.floatingTimerBar.resumeTimer()' : 'window.floatingTimerBar.stopTimer()'; this.bar.innerHTML = ` - `; diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index c9cfd2ba..cbf96d5a 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -73,10 +73,22 @@

{{ _('Time
+ {% if active_timer.is_paused %} + + {{ _('Paused') }} + + {% if active_timer.break_seconds %} + {{ _('Break') }}: {{ active_timer.break_formatted }} + {% endif %} + {% else %} {{ _('Running') }} + {% if active_timer.break_seconds %} + {{ _('Break so far') }}: {{ active_timer.break_formatted }} + {% endif %} + {% endif %}

{% if active_timer.project %} @@ -93,6 +105,7 @@

{{ _('Time

{{ _('Elapsed') }}: {{ active_timer.duration_formatted }}

+ {% if not active_timer.is_paused %}
{{ _('Adjust time') }}: @@ -104,8 +117,24 @@

{{ _('Time

+ {% endif %}

+ {% if active_timer.is_paused %} +
+ + + + {% else %} +
+ + + + {% endif %}
+
+ + +

{{ _('Break duration (HH:MM). Subtracted from total to get worked duration.') }}

+
diff --git a/app/templates/timer/manual_entry.html b/app/templates/timer/manual_entry.html index fa246435..b375f017 100644 --- a/app/templates/timer/manual_entry.html +++ b/app/templates/timer/manual_entry.html @@ -42,7 +42,11 @@ data-require-task="{{ 'true' if getattr(settings, 'time_entry_require_task', false) else 'false' }}" data-require-description="{{ 'true' if getattr(settings, 'time_entry_require_description', false) else 'false' }}" data-description-min-length="{{ getattr(settings, 'time_entry_description_min_length', 20) }}" - data-description-min-msg="{{ _('Description must be at least %(min)s characters', min=getattr(settings, 'time_entry_description_min_length', 20))|e }}"> + data-description-min-msg="{{ _('Description must be at least %(min)s characters', min=getattr(settings, 'time_entry_description_min_length', 20))|e }}" + data-break-after-hours-1="{{ getattr(settings, 'break_after_hours_1', none) or 6 }}" + data-break-minutes-1="{{ getattr(settings, 'break_minutes_1', none) or 30 }}" + data-break-after-hours-2="{{ getattr(settings, 'break_after_hours_2', none) or 9 }}" + data-break-minutes-2="{{ getattr(settings, 'break_minutes_2', none) or 45 }}"> @@ -114,6 +118,14 @@

{{ _('Optional: enter HH:MM for duration. You can combine with Start Date/Time to log time on a specific day.') }}

+
+ +
+ + +
+

{{ _('Optional: break duration (HH:MM). Subtracted from total time to get worked duration.') }}

+
@@ -275,6 +287,32 @@

+
+ {% if active_timer.is_paused %} + + + + + {% else %} +
+ + + + {% endif %}
+ {% if active_timer.break_seconds %} +

{{ _('Break') }}: {{ active_timer.break_formatted }}

+ {% endif %}
From a70285bfa9ee26e0b95f6cff45e9c70afcf95d5c Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:59:12 +0100 Subject: [PATCH 15/20] docs: add break time feature documentation and changelog (Issue #561) - Add docs/BREAK_TIME_FEATURE.md (timers pause/resume, manual break, API, settings) - CHANGELOG: add entry under [Unreleased] - FEATURES_COMPLETE: Timer Management and Manual Time Entry mention break time - docs/README: link to Break Time feature doc --- CHANGELOG.md | 1 + docs/BREAK_TIME_FEATURE.md | 72 ++++++++++++++++++++++++++++++++++++++ docs/FEATURES_COMPLETE.md | 4 ++- docs/README.md | 1 + 4 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 docs/BREAK_TIME_FEATURE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b0a493..c988a882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Break time for timers and manual time entries (Issue #561)** — Pause/resume running timers so time while paused counts as break; on stop, stored duration = (end − start) − break (with rounding). Manual time entries and edit form have an optional **Break** field (HH:MM); effective duration is (end − start) − break. Optional default break rules in Settings (e.g. >6 h → 30 min, >9 h → 45 min) power a **Suggest** button on the manual entry form; users can override. New columns: `time_entries.break_seconds`, `time_entries.paused_at`; Settings: `break_after_hours_1`, `break_minutes_1`, `break_after_hours_2`, `break_minutes_2`. API: `POST /api/v1/timer/pause`, `POST /api/v1/timer/resume`; timer status and time entry create/update accept and return `break_seconds`. See [docs/BREAK_TIME_FEATURE.md](docs/BREAK_TIME_FEATURE.md). - **Architecture refactor** — API v1 split into per-resource sub-blueprints (projects, tasks, clients, invoices, expenses, payments, mileage, deals, leads, contacts) under `app/routes/api_v1_*.py`; bootstrap slimmed by moving `setup_logging` to `app/utils/setup_logging.py` and legacy migrations to `app/utils/legacy_migrations.py`. Dashboard aggregations (top projects, time-by-project chart) moved into `AnalyticsService` (`get_dashboard_top_projects`, `get_time_by_project_chart`); dashboard route simplified to call services only. ARCHITECTURE.md updated with module table, API structure, and data flow; DEVELOPMENT.md with development workflow and build steps. ### Fixed diff --git a/docs/BREAK_TIME_FEATURE.md b/docs/BREAK_TIME_FEATURE.md new file mode 100644 index 00000000..1eedf53c --- /dev/null +++ b/docs/BREAK_TIME_FEATURE.md @@ -0,0 +1,72 @@ +# Break Time for Timers and Manual Time Entries + +**Issue:** [#561](https://github.com/DRYTRIX/TimeTracker/issues/561) + +This feature lets you account for break time when tracking work: either by pausing a running timer (so time while paused counts as break) or by entering break duration on manual time entries. Stored duration is always **worked time** (total span minus break). + +--- + +## For Running Timers + +### Pause and Resume + +- **Pause** — Stops the clock. Time while paused is not counted as work. When you click **Resume**, the elapsed pause time is added to **break** for that entry. +- **Resume** — Continues the timer and records the time you were paused as break. You can pause and resume multiple times; all pause segments are summed as break. +- **Stop & save** — Saves the entry. Stored **duration** = (end time − start time) − break time, then rounded according to your rounding settings. + +**Where it appears** + +- **Dashboard** — When a timer is running: **Pause** and **Stop & save**. When paused: **Resume** and **Stop & save**, plus a “Break: HH:MM:SS” line and a “Paused” badge. Elapsed time does not increase while paused. +- **Timer page** — Same Pause / Resume / Stop controls and break display. +- **Floating timer bar** — Shows paused state (e.g. amber icon); click to Resume or Stop depending on state. + +**API** + +- `POST /timer/pause` (web) and `POST /api/v1/timer/pause` (API) +- `POST /timer/resume` (web) and `POST /api/v1/timer/resume` (API) +- Timer status (`GET /timer/status`, `GET /api/v1/timer/status`) includes `paused`, `paused_at`, `break_seconds`, `break_formatted`. + +--- + +## For Manual Time Entries + +### Break field + +- On **Log Time** (manual entry) and **Edit time entry** you can enter **Break** in HH:MM (e.g. `0:30` for 30 minutes). +- **Effective duration** (what is stored and shown) = (end − start) − break. If you also use **Worked time**, that value is treated as net (after break); break can still be entered and is subtracted when both are present. +- Break is optional; leave it empty for no break. + +### Suggest break (manual entry) + +- A **Suggest** button next to the Break field uses optional default rules (e.g. Germany: >6 h → 30 min, >9 h → 45 min) to propose a break from the current start/end or worked time. You can change or clear the suggestion. + +--- + +## Default Break Rules (optional) + +Admins can configure default break rules in **Settings** (e.g. for labour-law style rules): + +- **Break after hours 1** / **Break minutes 1** — e.g. 6 h → 30 min +- **Break after hours 2** / **Break minutes 2** — e.g. 9 h → 45 min + +These are used only to **suggest** break in the manual entry form; the user can always override or leave break empty. They do not auto-apply. + +--- + +## Data model + +- **`time_entries.break_seconds`** — Total break in seconds for this entry (timer pauses or manual break). +- **`time_entries.paused_at`** — When set, the timer is paused; on resume, `(now − paused_at)` is added to `break_seconds` and `paused_at` is cleared. +- **`duration_seconds`** — Always **worked time**: (end − start) − break, then rounding. Reports and lists use this. + +--- + +## API summary + +| Action | Web route | API v1 route | +|---------------|---------------------|---------------------------| +| Pause timer | `POST /timer/pause` | `POST /api/v1/timer/pause` | +| Resume timer | `POST /timer/resume`| `POST /api/v1/timer/resume`| +| Timer status | `GET /timer/status` | `GET /api/v1/timer/status` | + +Time entry create/update (manual and API) accept optional **`break_seconds`**; response includes `break_seconds` and (for active timers) `paused_at`, `break_formatted`. diff --git a/docs/FEATURES_COMPLETE.md b/docs/FEATURES_COMPLETE.md index 6c11061b..1750ee81 100644 --- a/docs/FEATURES_COMPLETE.md +++ b/docs/FEATURES_COMPLETE.md @@ -50,6 +50,7 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management #### 3. **Manual Time Entry** - Add historical time entries +- **Break field (HH:MM)** — Optional break duration; effective duration = (end − start) − break. Suggest button uses default rules (e.g. >6 h → 30 min). See [Break Time Feature](BREAK_TIME_FEATURE.md). - Flexible date/time selection - Notes and tags support - Billable/non-billable flagging @@ -57,7 +58,8 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management #### 4. **Timer Management** - Start, stop, pause, and resume timers -- **Dashboard timer widget**: Pause (saves segment) and Stop; one-click "Resume (project)" to continue with the same project/task/notes; quick time adjustment (−15 / −5 / +5 / +15 min) while running +- **Break time (Issue #561)** — Pause a running timer so time while paused counts as break; on resume, that time is added to the entry’s break. Stored duration = (end − start) − break. See [Break Time Feature](BREAK_TIME_FEATURE.md). +- **Dashboard timer widget**: Pause (accumulates break) and Stop; one-click "Resume (project)" to continue with the same project/task/notes; quick time adjustment (−15 / −5 / +5 / +15 min) while running - Edit active timers - Delete timers - Timer history and audit trail diff --git a/docs/README.md b/docs/README.md index caf49779..94221b9b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -72,6 +72,7 @@ docs/ - **[Bulk Time Entry](BULK_TIME_ENTRY_README.md)** — Create multiple time entries at once - **[Time Entry Templates](TIME_ENTRY_TEMPLATES.md)** — Reusable time entry templates - **[Weekly Time Goals](WEEKLY_TIME_GOALS.md)** — Set and track weekly hour targets +- **[Break Time for timers and manual entries](BREAK_TIME_FEATURE.md)** — Pause timers (break time) and optional break field on manual entries (Issue #561) - **[Time Rounding](TIME_ROUNDING_PREFERENCES.md)** — Configurable time rounding - **[Role-Based Permissions](ADVANCED_PERMISSIONS.md)** — Granular access control - **[Subcontractor role and assigned clients](SUBCONTRACTOR_ROLE.md)** — Restrict users to specific clients and projects From daf3236c37d154802d96b953d5b982328bf627ce Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 18:44:53 +0100 Subject: [PATCH 16/20] feat(workforce): add delete for periods, time-off, leave types, and holidays (fixes #562) - Backend: WorkforceGovernanceService.delete_period, delete_leave_request, delete_leave_type, delete_holiday with permission and state checks - Web: POST delete routes in workforce blueprint; delete buttons in dashboard for periods (draft/rejected), time-off (draft/submitted/cancelled), leave types list, and company holidays (admin only) - API v1: DELETE endpoints for timesheet-periods, time-off/requests, time-off/leave-types, time-off/holidays (scopes and admin where required) - Desktop: deleteTimesheetPeriod/deleteTimeOffRequest in API client; Delete buttons and handlers in workforce view with confirmation and refresh - Mobile: deleteTimesheetPeriod/deleteTimeOffRequest in API client; Delete in popup menus for periods and time-off requests - Docs: WORKFORCE_DELETE.md, PROJECT_STRUCTURE and API_TOKEN_SCOPES updates --- app/routes/api_v1.py | 52 +++++++++++ app/routes/workforce.py | 42 +++++++++ app/services/workforce_governance_service.py | 60 +++++++++++++ app/templates/workforce/dashboard.html | 36 +++++++- desktop/src/renderer/js/api/client.js | 8 ++ desktop/src/renderer/js/app.js | 33 ++++++- docs/api/API_TOKEN_SCOPES.md | 18 +++- docs/development/PROJECT_STRUCTURE.md | 7 +- docs/features/WORKFORCE_DELETE.md | 67 ++++++++++++++ mobile/lib/data/api/api_client.dart | 10 +++ .../screens/finance_workforce_screen.dart | 87 ++++++++++++++++--- 11 files changed, 400 insertions(+), 20 deletions(-) create mode 100644 docs/features/WORKFORCE_DELETE.md diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index 72f5c287..67c986c2 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -4266,6 +4266,17 @@ def close_timesheet_period(period_id): return jsonify({"message": "Timesheet period closed", "timesheet_period": result["period"].to_dict()}) +@api_v1_bp.route("/timesheet-periods/", methods=["DELETE"]) +@require_api_token("write:time_entries") +def delete_timesheet_period_api(period_id): + from app.services.workforce_governance_service import WorkforceGovernanceService + + result = WorkforceGovernanceService().delete_period(period_id=period_id, actor_id=g.api_user.id) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not delete period")}), 400 + return jsonify({"message": "Timesheet period deleted"}) + + @api_v1_bp.route("/timesheet-policy", methods=["GET"]) @require_api_token("read:time_entries") def get_timesheet_policy(): @@ -4348,6 +4359,19 @@ def create_leave_type_api(): return jsonify({"message": "Leave type created", "leave_type": leave_type.to_dict()}), 201 +@api_v1_bp.route("/time-off/leave-types/", methods=["DELETE"]) +@require_api_token("write:reports") +def delete_leave_type_api(leave_type_id): + from app.services.workforce_governance_service import WorkforceGovernanceService + + if not g.api_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + result = WorkforceGovernanceService().delete_leave_type(leave_type_id) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not delete leave type")}), 400 + return jsonify({"message": "Leave type deleted"}) + + @api_v1_bp.route("/time-off/requests", methods=["GET"]) @require_api_token("read:time_entries") def list_time_off_requests_api(): @@ -4445,6 +4469,21 @@ def reject_time_off_request_api(request_id): return jsonify({"message": "Time-off request rejected", "time_off_request": result["request"].to_dict()}) +@api_v1_bp.route("/time-off/requests/", methods=["DELETE"]) +@require_api_token("write:time_entries") +def delete_time_off_request_api(request_id): + from app.services.workforce_governance_service import WorkforceGovernanceService + + result = WorkforceGovernanceService().delete_leave_request( + request_id=request_id, + actor_id=g.api_user.id, + actor_can_approve=_is_api_approver(g.api_user), + ) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not delete request")}), 400 + return jsonify({"message": "Time-off request deleted"}) + + @api_v1_bp.route("/time-off/balances", methods=["GET"]) @require_api_token("read:time_entries") def time_off_balances_api(): @@ -4494,6 +4533,19 @@ def create_holiday_api(): return jsonify({"message": "Holiday created", "holiday": holiday.to_dict()}), 201 +@api_v1_bp.route("/time-off/holidays/", methods=["DELETE"]) +@require_api_token("write:reports") +def delete_holiday_api(holiday_id): + from app.services.workforce_governance_service import WorkforceGovernanceService + + if not g.api_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + result = WorkforceGovernanceService().delete_holiday(holiday_id) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not delete holiday")}), 400 + return jsonify({"message": "Holiday deleted"}) + + # ==================== Payroll Export ==================== diff --git a/app/routes/workforce.py b/app/routes/workforce.py index 3772c892..cf66ee58 100644 --- a/app/routes/workforce.py +++ b/app/routes/workforce.py @@ -172,6 +172,14 @@ def close_period(period_id): return redirect(url_for("workforce.dashboard")) +@workforce_bp.route("/workforce/periods//delete", methods=["POST"]) +@login_required +def delete_period(period_id): + result = WorkforceGovernanceService().delete_period(period_id=period_id, actor_id=current_user.id) + flash(_(result.get("message", "Period deleted")) if result.get("success") else _(result.get("message", "Could not delete period")), "success" if result.get("success") else "error") + return redirect(url_for("workforce.dashboard")) + + @workforce_bp.route("/workforce/policy", methods=["POST"]) @login_required def update_policy(): @@ -222,6 +230,17 @@ def create_leave_type(): return redirect(url_for("workforce.dashboard")) +@workforce_bp.route("/workforce/leave-types//delete", methods=["POST"]) +@login_required +def delete_leave_type(leave_type_id): + if not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + result = WorkforceGovernanceService().delete_leave_type(leave_type_id) + flash(_(result.get("message", "Leave type deleted")) if result.get("success") else _(result.get("message", "Could not delete leave type")), "success" if result.get("success") else "error") + return redirect(url_for("workforce.dashboard")) + + @workforce_bp.route("/workforce/time-off/request", methods=["POST"]) @login_required def create_time_off_request(): @@ -284,6 +303,18 @@ def reject_time_off_request(request_id): return redirect(url_for("workforce.dashboard")) +@workforce_bp.route("/workforce/time-off//delete", methods=["POST"]) +@login_required +def delete_time_off_request(request_id): + result = WorkforceGovernanceService().delete_leave_request( + request_id=request_id, + actor_id=current_user.id, + actor_can_approve=_can_approve(), + ) + flash(_(result.get("message", "Time-off request deleted")) if result.get("success") else _(result.get("message", "Could not delete request")), "success" if result.get("success") else "error") + return redirect(url_for("workforce.dashboard")) + + @workforce_bp.route("/workforce/holidays/create", methods=["POST"]) @login_required def create_holiday(): @@ -305,6 +336,17 @@ def create_holiday(): return redirect(url_for("workforce.dashboard")) +@workforce_bp.route("/workforce/holidays//delete", methods=["POST"]) +@login_required +def delete_holiday(holiday_id): + if not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + result = WorkforceGovernanceService().delete_holiday(holiday_id) + flash(_(result.get("message", "Holiday deleted")) if result.get("success") else _(result.get("message", "Could not delete holiday")), "success" if result.get("success") else "error") + return redirect(url_for("workforce.dashboard")) + + @workforce_bp.route("/workforce/reports/payroll.csv", methods=["GET"]) @login_required def payroll_export_csv(): diff --git a/app/services/workforce_governance_service.py b/app/services/workforce_governance_service.py index cbff7244..98f209cd 100644 --- a/app/services/workforce_governance_service.py +++ b/app/services/workforce_governance_service.py @@ -462,3 +462,63 @@ def payroll_rows( item["non_billable_hours"] = round(item["non_billable_hours"], 2) out.sort(key=lambda x: (x["week_year"], x["week_number"], x["username"] or "")) return out + + def delete_period(self, period_id: int, actor_id: int) -> Dict[str, Any]: + """Delete a timesheet period. Only draft or rejected periods; actor must be owner or admin.""" + period = TimesheetPeriod.query.get(period_id) + if not period: + return {"success": False, "message": "Timesheet period not found"} + user = User.query.get(actor_id) + if not user: + return {"success": False, "message": "User not found"} + if period.user_id != actor_id and not user.is_admin: + return {"success": False, "message": "Only the period owner or an admin can delete it"} + status = period.status.value if hasattr(period.status, "value") else str(period.status) + if status not in (TimesheetPeriodStatus.DRAFT.value, TimesheetPeriodStatus.REJECTED.value): + return {"success": False, "message": "Only draft or rejected periods can be deleted"} + db.session.delete(period) + db.session.commit() + return {"success": True} + + def delete_leave_request( + self, request_id: int, actor_id: int, actor_can_approve: bool = False + ) -> Dict[str, Any]: + """Delete a time-off request. Only draft, submitted, or cancelled; actor must be owner or approver.""" + req = TimeOffRequest.query.get(request_id) + if not req: + return {"success": False, "message": "Time-off request not found"} + if req.user_id != actor_id and not actor_can_approve: + return {"success": False, "message": "Only the request owner or an approver can delete it"} + status = req.status.value if hasattr(req.status, "value") else str(req.status) + if status not in ( + TimeOffRequestStatus.DRAFT.value, + TimeOffRequestStatus.SUBMITTED.value, + TimeOffRequestStatus.CANCELLED.value, + ): + return {"success": False, "message": "Only draft, submitted, or cancelled requests can be deleted"} + db.session.delete(req) + db.session.commit() + return {"success": True} + + def delete_leave_type(self, leave_type_id: int) -> Dict[str, Any]: + """Delete a leave type. Fails if any time-off request references it.""" + leave_type = LeaveType.query.get(leave_type_id) + if not leave_type: + return {"success": False, "message": "Leave type not found"} + if leave_type.requests.count() > 0: + return { + "success": False, + "message": "Cannot delete leave type that has time-off requests", + } + db.session.delete(leave_type) + db.session.commit() + return {"success": True} + + def delete_holiday(self, holiday_id: int) -> Dict[str, Any]: + """Delete a company holiday.""" + holiday = CompanyHoliday.query.get(holiday_id) + if not holiday: + return {"success": False, "message": "Holiday not found"} + db.session.delete(holiday) + db.session.commit() + return {"success": True} diff --git a/app/templates/workforce/dashboard.html b/app/templates/workforce/dashboard.html index ac369d6c..b5d1e6f6 100644 --- a/app/templates/workforce/dashboard.html +++ b/app/templates/workforce/dashboard.html @@ -87,6 +87,13 @@

{{ _('Timesheet Periods') }}

{% endif %} + {% set p_status_val = p.status.value if p.status is defined and p.status.value is defined else (p.status|string) %} + {% if p_status_val in ['draft', 'rejected'] and (p.user_id == current_user.id or current_user.is_admin) %} +
+ + + + {% endif %}
@@ -180,6 +187,12 @@

{{ _('Time-Off Requests') }}

{% endif %} + {% if r_status in ['draft', 'submitted', 'cancelled'] and (r.user_id == current_user.id or can_approve) %} +
+ + + + {% endif %} {% endfor %} @@ -245,6 +258,19 @@

{{ _('Leave Types') }}

+
+ {% for lt in leave_types %} +
+ {{ lt.name }} ({{ lt.code }}){% if not lt.enabled %} — {{ _('disabled') }}{% endif %} +
+ + + +
+ {% else %} +

{{ _('No leave types configured.') }}

+ {% endfor %} +
@@ -257,9 +283,15 @@

{{ _('Company Holidays') }}

-
+
{% for h in holidays %} -
{{ h.start_date }} - {{ h.end_date }}: {{ h.name }}{% if h.region %} ({{ h.region }}){% endif %}
+
+ {{ h.start_date }} - {{ h.end_date }}: {{ h.name }}{% if h.region %} ({{ h.region }}){% endif %} +
+ + + +
{% else %}
{{ _('No holidays configured.') }}
{% endfor %} diff --git a/desktop/src/renderer/js/api/client.js b/desktop/src/renderer/js/api/client.js index 6a6c86e1..f07c27ea 100644 --- a/desktop/src/renderer/js/api/client.js +++ b/desktop/src/renderer/js/api/client.js @@ -234,6 +234,10 @@ class ApiClient { return await this.client.post(`/api/v1/timesheet-periods/${periodId}/reject`, data); } + async deleteTimesheetPeriod(periodId) { + return await this.client.delete(`/api/v1/timesheet-periods/${periodId}`); + } + async getLeaveTypes() { return await this.client.get('/api/v1/time-off/leave-types'); } @@ -275,6 +279,10 @@ class ApiClient { if (comment) data.comment = comment; return await this.client.post(`/api/v1/time-off/requests/${requestId}/reject`, data); } + + async deleteTimeOffRequest(requestId) { + return await this.client.delete(`/api/v1/time-off/requests/${requestId}`); + } } // Export for use in other files diff --git a/desktop/src/renderer/js/app.js b/desktop/src/renderer/js/app.js index 6bcf3c2b..47dc9ca7 100644 --- a/desktop/src/renderer/js/app.js +++ b/desktop/src/renderer/js/app.js @@ -81,11 +81,12 @@ async function loadCurrentUserProfile() { const role = String(user.role || '').toLowerCase(); const roleCanApprove = ['admin', 'owner', 'manager', 'approver'].includes(role); state.currentUserProfile = { + id: user.id, is_admin: Boolean(user.is_admin), can_approve: Boolean(user.is_admin) || roleCanApprove, }; } catch (_) { - state.currentUserProfile = { is_admin: false, can_approve: false }; + state.currentUserProfile = { id: null, is_admin: false, can_approve: false }; } } @@ -947,6 +948,9 @@ function renderPeriods() { ${(String(period.status || '').toLowerCase() === 'submitted' && state.currentUserProfile.can_approve) ? `` : ''} + ${['draft', 'rejected'].includes(String(period.status || '').toLowerCase()) + ? `` + : ''}
`).join(''); @@ -1007,6 +1011,9 @@ function renderTimeOffRequests() {
${status}
${canReview ? `` : ''} ${canReview ? `` : ''} + ${['draft', 'submitted', 'cancelled'].includes(String(status).toLowerCase()) && (req.user_id === state.currentUserProfile.id || state.currentUserProfile.can_approve) + ? `` + : ''} `; @@ -1152,6 +1159,18 @@ async function reviewTimesheetPeriodAction(periodId, approve) { } } +async function deleteTimesheetPeriodAction(periodId) { + if (!state.apiClient) return; + if (!confirm('Are you sure you want to delete this timesheet period?')) return; + try { + await state.apiClient.deleteTimesheetPeriod(periodId); + showSuccess('Timesheet period deleted'); + await loadWorkforce(); + } catch (error) { + showError('Failed to delete period: ' + (error.response?.data?.error || error.message)); + } +} + async function showCreateTimeOffDialog() { if (!state.apiClient) return; @@ -1348,6 +1367,18 @@ async function reviewTimeOffRequestAction(requestId, approve) { } } +async function deleteTimeOffRequestAction(requestId) { + if (!state.apiClient) return; + if (!confirm('Are you sure you want to delete this time-off request?')) return; + try { + await state.apiClient.deleteTimeOffRequest(requestId); + showSuccess('Time-off request deleted'); + await loadWorkforce(); + } catch (error) { + showError('Failed to delete time-off request: ' + (error.response?.data?.error || error.message)); + } +} + async function loadSettings() { // Load current settings const serverUrl = await storeGet('server_url') || ''; diff --git a/docs/api/API_TOKEN_SCOPES.md b/docs/api/API_TOKEN_SCOPES.md index 510d6d85..55d5f665 100644 --- a/docs/api/API_TOKEN_SCOPES.md +++ b/docs/api/API_TOKEN_SCOPES.md @@ -86,13 +86,15 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \ ``` #### `write:time_entries` -**Grants**: Create, update, and delete time entries; control timer +**Grants**: Create, update, and delete time entries; control timer; timesheet periods and time-off requests **Endpoints**: - `POST /api/v1/time-entries` - Create time entry - `PUT /api/v1/time-entries/{id}` - Update time entry - `DELETE /api/v1/time-entries/{id}` - Delete time entry - `POST /api/v1/timer/start` - Start timer - `POST /api/v1/timer/stop` - Stop timer +- `DELETE /api/v1/timesheet-periods/{id}` - Delete timesheet period (draft/rejected only; owner or admin) +- `DELETE /api/v1/time-off/requests/{id}` - Delete time-off request (draft/submitted/cancelled; owner or approver) **Use Cases**: - Time tracking integrations @@ -199,9 +201,11 @@ curl -X POST https://your-domain.com/api/v1/clients \ ### Reports #### `read:reports` -**Grants**: Access reporting and analytics endpoints +**Grants**: Access reporting and analytics endpoints; read leave types and holidays **Endpoints**: - `GET /api/v1/reports/summary` - Get summary reports +- `GET /api/v1/time-off/leave-types` - List leave types +- `GET /api/v1/time-off/holidays` - List company holidays **Use Cases**: - Business intelligence tools @@ -219,6 +223,16 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \ "https://your-domain.com/api/v1/reports/summary?start_date=2024-01-01&end_date=2024-01-31" ``` +#### `write:reports` +**Grants**: Create and delete leave types and company holidays (workforce admin) +**Endpoints**: +- `POST /api/v1/time-off/leave-types` - Create leave type (admin only) +- `DELETE /api/v1/time-off/leave-types/{id}` - Delete leave type (admin only; blocked if it has time-off requests) +- `POST /api/v1/time-off/holidays` - Create company holiday (admin only) +- `DELETE /api/v1/time-off/holidays/{id}` - Delete company holiday (admin only) + +**Permissions**: Admin only for these endpoints. + --- ### Users diff --git a/docs/development/PROJECT_STRUCTURE.md b/docs/development/PROJECT_STRUCTURE.md index a18558d6..5dbc3a18 100644 --- a/docs/development/PROJECT_STRUCTURE.md +++ b/docs/development/PROJECT_STRUCTURE.md @@ -115,10 +115,11 @@ TimeTracker/ Timesheet periods, policies, and time-off tracking for payroll and compliance: - **Models**: `TimesheetPeriod`, `TimesheetPolicy`, `TimeOff` (in `app/models/`) -- **Routes**: `workforce` blueprint — dashboard, period close, policies, time-off -- **Services**: `workforce_governance_service.py` — period close, policy checks, time-off logic -- **Templates**: `app/templates/workforce/` (e.g. dashboard) +- **Routes**: `workforce` blueprint — dashboard, period close, policies, time-off, **delete** (periods, time-off requests, leave types, holidays) +- **Services**: `workforce_governance_service.py` — period close, policy checks, time-off logic, **delete** (period, leave request, leave type, holiday) +- **Templates**: `app/templates/workforce/` (e.g. dashboard, with delete buttons where allowed) - **Migration**: `132_add_timesheet_governance_and_time_off.py` +- **Docs**: [Workforce delete feature](../features/WORKFORCE_DELETE.md) (Issue #562) ## ✅ Task Management Feature diff --git a/docs/features/WORKFORCE_DELETE.md b/docs/features/WORKFORCE_DELETE.md new file mode 100644 index 00000000..78c78e74 --- /dev/null +++ b/docs/features/WORKFORCE_DELETE.md @@ -0,0 +1,67 @@ +# Workforce Tab: Delete Entries + +This feature adds the ability to delete timesheet periods, time-off requests, leave types, and company holidays from the Workforce tab (web, desktop, and mobile). It addresses [Issue #562](https://github.com/DRYTRIX/TimeTracker/issues/562). + +## What Can Be Deleted + +| Entity | Who can delete | When | +|--------|----------------|------| +| **Timesheet period** | Owner or admin | Only when status is **draft** or **rejected** | +| **Time-off request** | Owner or approver/admin | Only when status is **draft**, **submitted**, or **cancelled** | +| **Leave type** | Admin only | Only if no time-off request uses this leave type | +| **Company holiday** | Admin only | Always (no dependencies) | + +Submitted, approved, closed, or rejected records that affect audit or reporting cannot be deleted. + +## Web (Workforce dashboard) + +- **Timesheet periods:** Each draft or rejected period has a **Delete** button (owner or admin). +- **Time-off requests:** Each draft, submitted, or cancelled request has a **Delete** button (owner or approver). +- **Leave types:** In the admin section, each leave type is listed with a **Delete** button. Delete is blocked with an error if the leave type has any time-off requests. +- **Company holidays:** Each holiday in the list has a **Delete** button (admin only). + +All delete actions use POST forms with CSRF protection and redirect back to the dashboard after success or error. + +## API v1 (Desktop & mobile) + +Delete is exposed as HTTP `DELETE`: + +| Endpoint | Scope | Notes | +|----------|--------|--------| +| `DELETE /api/v1/timesheet-periods/{id}` | `write:time_entries` | Owner or admin; period must be draft or rejected | +| `DELETE /api/v1/time-off/requests/{id}` | `write:time_entries` | Owner or approver; request must be draft, submitted, or cancelled | +| `DELETE /api/v1/time-off/leave-types/{id}` | `write:reports` | Admin only; returns 400 if leave type has requests | +| `DELETE /api/v1/time-off/holidays/{id}` | `write:reports` | Admin only | + +Success: `200` with JSON `{ "message": "..." }`. +Failure: `400` with `{ "error": "..." }` or `403` for permission errors. + +## Desktop app + +- **Timesheet periods:** Delete button on each draft or rejected period; confirmation dialog then refresh. +- **Time-off requests:** Delete button on each draft, submitted, or cancelled request (own requests or when user can approve); confirmation then refresh. + +See `desktop/src/renderer/js/api/client.js` (`deleteTimesheetPeriod`, `deleteTimeOffRequest`) and `desktop/src/renderer/js/app.js` (workforce render and handlers). + +## Mobile app + +- **Timesheet periods:** Popup menu on each period includes **Delete** when status is draft or rejected. +- **Time-off requests:** Popup menu includes **Delete** when status is draft, submitted, or cancelled and the user is owner or approver. + +See `mobile/lib/data/api/api_client.dart` and `mobile/lib/presentation/screens/finance_workforce_screen.dart`. + +## Backend + +- **Service:** `app/services/workforce_governance_service.py` + - `delete_period(period_id, actor_id)` + - `delete_leave_request(request_id, actor_id, actor_can_approve=False)` + - `delete_leave_type(leave_type_id)` + - `delete_holiday(holiday_id)` +- **Web routes:** `app/routes/workforce.py` — POST routes for each delete, CSRF and permissions. +- **API routes:** `app/routes/api_v1.py` — DELETE endpoints with token scopes and admin checks where required. + +## Risks and notes + +- Deleting a leave type that has time-off requests is prevented; the API and web UI return a clear error. +- Only draft or rejected periods can be deleted to keep audit history for submitted/approved/closed periods. +- Only draft, submitted, or cancelled time-off requests can be deleted; approved or rejected ones are kept for reporting. diff --git a/mobile/lib/data/api/api_client.dart b/mobile/lib/data/api/api_client.dart index a2130d95..be4dccae 100644 --- a/mobile/lib/data/api/api_client.dart +++ b/mobile/lib/data/api/api_client.dart @@ -319,6 +319,11 @@ class ApiClient { return response.data as Map; } + Future> deleteTimesheetPeriod(int periodId) async { + final response = await _dio.delete('/api/v1/timesheet-periods/$periodId'); + return response.data as Map; + } + Future> getLeaveTypes() async { final response = await _dio.get('/api/v1/time-off/leave-types'); return response.data as Map; @@ -377,4 +382,9 @@ class ApiClient { final response = await _dio.post('/api/v1/time-off/requests/$requestId/reject', data: data); return response.data as Map; } + + Future> deleteTimeOffRequest(int requestId) async { + final response = await _dio.delete('/api/v1/time-off/requests/$requestId'); + return response.data as Map; + } } diff --git a/mobile/lib/presentation/screens/finance_workforce_screen.dart b/mobile/lib/presentation/screens/finance_workforce_screen.dart index 0be400b0..b2ad847f 100644 --- a/mobile/lib/presentation/screens/finance_workforce_screen.dart +++ b/mobile/lib/presentation/screens/finance_workforce_screen.dart @@ -18,6 +18,7 @@ class _FinanceWorkforceScreenState extends ConsumerState final TextEditingController _expenseFilterController = TextEditingController(); final TextEditingController _timeOffFilterController = TextEditingController(); bool _canApprove = false; + int? _currentUserId; String _invoiceFilter = ''; String _expenseFilter = ''; String _timeOffFilter = ''; @@ -76,6 +77,7 @@ class _FinanceWorkforceScreenState extends ConsumerState final roleCanApprove = role == 'admin' || role == 'owner' || role == 'manager' || role == 'approver'; if (mounted) { _canApprove = (user['is_admin'] == true) || roleCanApprove; + _currentUserId = (user['id'] as num?)?.toInt(); } final invoiceTotalPages = ((invoicesRes['pagination'] ?? const {})['pages'] as num?)?.toInt() ?? 1; @@ -189,6 +191,24 @@ class _FinanceWorkforceScreenState extends ConsumerState } } + Future _deletePeriod(int periodId) async { + try { + final client = await ref.read(apiClientProvider.future); + if (client == null) return; + await client.deleteTimesheetPeriod(periodId); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Timesheet period deleted')), + ); + await _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Delete failed: $e')), + ); + } + } + Future _createExpense({ required String title, required String category, @@ -563,6 +583,24 @@ class _FinanceWorkforceScreenState extends ConsumerState } } + Future _deleteTimeOffRequest(int requestId) async { + try { + final client = await ref.read(apiClientProvider.future); + if (client == null) return; + await client.deleteTimeOffRequest(requestId); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Time-off request deleted')), + ); + await _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Delete failed: $e')), + ); + } + } + Future _openCreateTimeOffDialog(_FinanceWorkforceData data) async { if (data.leaveTypes.isEmpty) { if (!mounted) return; @@ -946,7 +984,12 @@ class _FinanceWorkforceScreenState extends ConsumerState final start = (req['start_date'] ?? '').toString(); final end = (req['end_date'] ?? '').toString(); final leaveType = (req['leave_type_name'] ?? 'Leave').toString(); + final reqUserId = (req['user_id'] as num?)?.toInt(); final isSubmitted = status.toLowerCase() == 'submitted'; + final canDelete = requestId != null && + ['draft', 'submitted', 'cancelled'].contains(status.toLowerCase()) && + (reqUserId == _currentUserId || _canApprove); + final showMenu = (isSubmitted && _canApprove) || canDelete; return ListTile( dense: true, contentPadding: EdgeInsets.zero, @@ -956,19 +999,28 @@ class _FinanceWorkforceScreenState extends ConsumerState mainAxisSize: MainAxisSize.min, children: [ Text(status), - if (isSubmitted && requestId != null && _canApprove) + if (showMenu && requestId != null) PopupMenuButton( onSelected: (value) async { if (value == 'approve') { await _reviewTimeOffRequest(requestId: requestId, approve: true); } else if (value == 'reject') { await _reviewTimeOffRequest(requestId: requestId, approve: false); + } else if (value == 'delete') { + await _deleteTimeOffRequest(requestId); } }, - itemBuilder: (context) => const [ - PopupMenuItem(value: 'approve', child: Text('Approve')), - PopupMenuItem(value: 'reject', child: Text('Reject')), - ], + itemBuilder: (context) { + final items = >[]; + if (isSubmitted && _canApprove) { + items.add(const PopupMenuItem(value: 'approve', child: Text('Approve'))); + items.add(const PopupMenuItem(value: 'reject', child: Text('Reject'))); + } + if (canDelete) { + items.add(const PopupMenuItem(value: 'delete', child: Text('Delete'))); + } + return items; + }, ), ], ), @@ -1034,6 +1086,8 @@ class _FinanceWorkforceScreenState extends ConsumerState final periodId = period['id'] as int?; final canSubmit = status.toLowerCase() == 'draft' && periodId != null; final canReview = _canApprove && status.toLowerCase() == 'submitted' && periodId != null; + final canDelete = periodId != null && + ['draft', 'rejected'].contains(status.toLowerCase()); return ListTile( dense: true, contentPadding: EdgeInsets.zero, @@ -1044,19 +1098,28 @@ class _FinanceWorkforceScreenState extends ConsumerState onPressed: () => _submitPeriod(periodId), child: const Text('Submit'), ) - : (canReview + : (canReview || canDelete ? PopupMenuButton( onSelected: (value) async { if (value == 'approve') { - await _reviewPeriod(periodId: periodId, approve: true); + await _reviewPeriod(periodId: periodId!, approve: true); } else if (value == 'reject') { - await _reviewPeriod(periodId: periodId, approve: false); + await _reviewPeriod(periodId: periodId!, approve: false); + } else if (value == 'delete') { + await _deletePeriod(periodId!); + } + }, + itemBuilder: (context) { + final items = >[]; + if (canReview) { + items.add(const PopupMenuItem(value: 'approve', child: Text('Approve'))); + items.add(const PopupMenuItem(value: 'reject', child: Text('Reject'))); + } + if (canDelete) { + items.add(const PopupMenuItem(value: 'delete', child: Text('Delete'))); } + return items; }, - itemBuilder: (context) => const [ - PopupMenuItem(value: 'approve', child: Text('Approve')), - PopupMenuItem(value: 'reject', child: Text('Reject')), - ], ) : null), ); From 1ad899834b4342a38eec27acb811e2eb7e836f11 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 18:48:29 +0100 Subject: [PATCH 17/20] fix(time-entries): apply date filter and export by current filters (Issue #555) - Add visible Apply filters button in filter header so users can apply Start/End date and other filters without scrolling; expand panel if collapsed - Keep CSV/PDF export links in sync with current filters: set href from URL on load and update on form change so export (including right-click Open in new tab / Save link as) always uses the filtered date range - Document fix in CHANGELOG under [Unreleased] --- CHANGELOG.md | 1 + .../timer/time_entries_overview.html | 36 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c988a882..04ed2ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Architecture refactor** — API v1 split into per-resource sub-blueprints (projects, tasks, clients, invoices, expenses, payments, mileage, deals, leads, contacts) under `app/routes/api_v1_*.py`; bootstrap slimmed by moving `setup_logging` to `app/utils/setup_logging.py` and legacy migrations to `app/utils/legacy_migrations.py`. Dashboard aggregations (top projects, time-by-project chart) moved into `AnalyticsService` (`get_dashboard_top_projects`, `get_time_by_project_chart`); dashboard route simplified to call services only. ARCHITECTURE.md updated with module table, API structure, and data flow; DEVELOPMENT.md with development workflow and build steps. ### Fixed +- **Time Entries date filter and export (Issue #555)** — Start/End date filters were hard to discover and exports ignored them. The Time Entries overview now has a visible **Apply filters** button in the filter header (next to Clear Filters and Export) so users can apply date and other filters without scrolling. CSV and PDF export links always use the current filter parameters: export href is set from the page URL on load and updated whenever filter form values change, so left-click export, right-click "Open in new tab", and "Save link as" all produce filtered exports. The in-form Apply filters button and the header button both trigger the same filter logic; clicking the header button expands the filter panel if it is collapsed. - **Log Time / Edit Time Entry on mobile (Issue #557)** — Opening the manual time entry ("Log Time") or edit time entry page on mobile could freeze or crash the browser. The Toast UI Editor (WYSIWYG markdown editor) for the notes field is heavy and causes freezes on mobile Safari/Chrome. On viewports ≤767px we now skip loading the editor and show a plain textarea for notes instead; desktop behavior is unchanged. Manual entry and edit timer templates load Toast UI only when not in mobile view. - **Stop & Save error (Issue #563)** — Fixed error after clicking "Stop & Save" on the dashboard. The post-timer toast was building the "View time entries" URL with the wrong route name (`timer.time_entries`); the correct endpoint is `timer.time_entries_overview`. Time entries were already saved; the error occurred when rendering the dashboard redirect. - **Dashboard cache (Issue #549)** — Removed dashboard caching that caused "Instance not bound to a Session" and "Database Error" on second visit. Cached template data contained ORM objects (active_timer, recent_entries, top_projects, templates, etc.) that become detached when served in a different request. diff --git a/app/templates/timer/time_entries_overview.html b/app/templates/timer/time_entries_overview.html index 776f500d..cd96f93f 100644 --- a/app/templates/timer/time_entries_overview.html +++ b/app/templates/timer/time_entries_overview.html @@ -31,6 +31,9 @@

{{ _('Filters') }}

{{ _('Exports use current filters') }} + @@ -642,7 +645,36 @@

{{ _('Mark Selected Entries as Paid/Unpai } // Initialize export links from current form/URL (Issue #555: keep export in sync with filters) - try { updateExportLink(buildFilterUrl()); } catch (_) {} + try { + updateExportLink(buildFilterUrl()); + // Fallback: if page was loaded with query params, use them for export href (e.g. right-click Open in new tab) + if (window.location.search) { + const csvBtn = document.getElementById('exportCsvBtn'); + const pdfBtn = document.getElementById('exportPdfBtn'); + const base = csvBtn ? (csvBtn.getAttribute('data-export-base') || '/time-entries/export/csv') : '/time-entries/export/csv'; + const pdfBase = pdfBtn ? (pdfBtn.getAttribute('data-export-base') || '/time-entries/export/pdf') : '/time-entries/export/pdf'; + const qs = window.location.search.slice(1); + if (csvBtn) csvBtn.href = base + (qs ? '?' + qs : ''); + if (pdfBtn) pdfBtn.href = pdfBase + (qs ? '?' + qs : ''); + } + } catch (_) {} + + // Header "Apply filters" button (Issue #555: visible filter action) + const applyFiltersBtn = document.getElementById('applyFiltersBtn'); + if (applyFiltersBtn) { + applyFiltersBtn.addEventListener('click', function() { + const filterBody = document.getElementById('filterBody'); + const icon = document.getElementById('filterToggleIcon'); + if (filterBody && filterBody.classList.contains('hidden')) { + filterBody.classList.remove('hidden'); + if (icon) { icon.classList.remove('fa-chevron-down'); icon.classList.add('fa-chevron-up'); } + } + if (filterTimeout) clearTimeout(filterTimeout); + if (searchTimeout) clearTimeout(searchTimeout); + lastUrl = null; + applyFilters(); + }); + } // Export CSV/PDF: always use current form params on click (Issue #555: export respects date/filters) const exportCsvBtn = document.getElementById('exportCsvBtn'); @@ -672,6 +704,7 @@

{{ _('Mark Selected Entries as Paid/Unpai const t = e.target; if (!t || !(t instanceof Element)) return; if (t.matches('select') || t.matches('input[type="date"]') || t.matches('input[type="text"]')) { + updateExportLink(buildFilterUrl()); // Issue #555: export href reflects current form debouncedApplyFilters(100); } }); @@ -680,6 +713,7 @@

{{ _('Mark Selected Entries as Paid/Unpai const t = e.target; if (!t || !(t instanceof Element)) return; if (t.matches('input[name="search"]')) { + updateExportLink(buildFilterUrl()); // Issue #555: export href reflects current form debouncedSearch(500); } }); From 1d3a1541e226f61507524385af85aa55548904cd Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 19:18:20 +0100 Subject: [PATCH 18/20] feat(mileage,per_diem): add CSV/PDF export and filter-aware export (Issue #564) - Mileage: Add GET /mileage/export/csv and /mileage/export/pdf with same filters as list (status, project, client, date range, search). Export buttons in list header; JS builds export URL from current filter form. - Mileage PDF: New app/utils/mileage_pdf.py (ReportLab, landscape A4, totals row for distance and amount). - Per diem: Add Client filter to list (with client-lock/single-client handling). Add GET /per-diem/export/csv and /per-diem/export/pdf. - Per diem PDF: New app/utils/per_diem_pdf.py (same style as mileage). - Export links always use current filters (no need to submit first). - CHANGELOG and docs/import_export/README updated. --- CHANGELOG.md | 1 + app/routes/mileage.py | 169 ++++++++++++++++++++ app/routes/per_diem.py | 166 +++++++++++++++++++- app/templates/mileage/list.html | 68 +++++++- app/templates/per_diem/list.html | 89 ++++++++++- app/utils/mileage_pdf.py | 262 +++++++++++++++++++++++++++++++ app/utils/per_diem_pdf.py | 255 ++++++++++++++++++++++++++++++ docs/import_export/README.md | 2 + 8 files changed, 1003 insertions(+), 9 deletions(-) create mode 100644 app/utils/mileage_pdf.py create mode 100644 app/utils/per_diem_pdf.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ed2ef3..e1f59a8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Mileage and Per Diem export and filter (Issue #564)** — Mileage and Per Diem now support CSV and PDF export using the same filter set as the list view, matching Time Entries behavior. **Mileage**: Export CSV and Export PDF buttons in the filter card; exports use current filters (search, status, project, client, date range). Routes: `GET /mileage/export/csv`, `GET /mileage/export/pdf`. PDF report via [app/utils/mileage_pdf.py](app/utils/mileage_pdf.py) (ReportLab, landscape A4, totals row). **Per diem**: Client filter added to the list form (with client-lock/single-client handling); Export CSV and Export PDF buttons; routes `GET /per-diem/export/csv`, `GET /per-diem/export/pdf`. PDF via [app/utils/per_diem_pdf.py](app/utils/per_diem_pdf.py). Export links are built from the current filter form (JS), so applied filters apply to both the list and the downloaded file. - **Break time for timers and manual time entries (Issue #561)** — Pause/resume running timers so time while paused counts as break; on stop, stored duration = (end − start) − break (with rounding). Manual time entries and edit form have an optional **Break** field (HH:MM); effective duration is (end − start) − break. Optional default break rules in Settings (e.g. >6 h → 30 min, >9 h → 45 min) power a **Suggest** button on the manual entry form; users can override. New columns: `time_entries.break_seconds`, `time_entries.paused_at`; Settings: `break_after_hours_1`, `break_minutes_1`, `break_after_hours_2`, `break_minutes_2`. API: `POST /api/v1/timer/pause`, `POST /api/v1/timer/resume`; timer status and time entry create/update accept and return `break_seconds`. See [docs/BREAK_TIME_FEATURE.md](docs/BREAK_TIME_FEATURE.md). - **Architecture refactor** — API v1 split into per-resource sub-blueprints (projects, tasks, clients, invoices, expenses, payments, mileage, deals, leads, contacts) under `app/routes/api_v1_*.py`; bootstrap slimmed by moving `setup_logging` to `app/utils/setup_logging.py` and legacy migrations to `app/utils/legacy_migrations.py`. Dashboard aggregations (top projects, time-by-project chart) moved into `AnalyticsService` (`get_dashboard_top_projects`, `get_time_by_project_chart`); dashboard route simplified to call services only. ARCHITECTURE.md updated with module table, API structure, and data flow; DEVELOPMENT.md with development workflow and build steps. diff --git a/app/routes/mileage.py b/app/routes/mileage.py index 1f418fa2..53561c24 100644 --- a/app/routes/mileage.py +++ b/app/routes/mileage.py @@ -142,6 +142,175 @@ def list_mileage(): ) +def _mileage_export_query(): + """Build the same filtered query as list_mileage (no pagination). Caller must apply .all().""" + from app.utils.client_lock import enforce_locked_client_id + from sqlalchemy.orm import joinedload + + status = request.args.get("status", "").strip() + project_id = request.args.get("project_id", type=int) + client_id = request.args.get("client_id", type=int) + client_id = enforce_locked_client_id(client_id) + start_date = request.args.get("start_date", "").strip() + end_date = request.args.get("end_date", "").strip() + search = request.args.get("search", "").strip() + + query = Mileage.query.options( + joinedload(Mileage.user), + joinedload(Mileage.project), + joinedload(Mileage.client), + ) + + if not current_user.is_admin: + query = query.filter(db.or_(Mileage.user_id == current_user.id, Mileage.approved_by == current_user.id)) + + if status: + query = query.filter(Mileage.status == status) + if project_id: + query = query.filter(Mileage.project_id == project_id) + if client_id: + query = query.filter(Mileage.client_id == client_id) + if start_date: + try: + start = datetime.strptime(start_date, "%Y-%m-%d").date() + query = query.filter(Mileage.trip_date >= start) + except ValueError: + pass + if end_date: + try: + end = datetime.strptime(end_date, "%Y-%m-%d").date() + query = query.filter(Mileage.trip_date <= end) + except ValueError: + pass + if search: + like = f"%{search}%" + query = query.filter( + db.or_( + Mileage.purpose.ilike(like), + Mileage.description.ilike(like), + Mileage.start_location.ilike(like), + Mileage.end_location.ilike(like), + ) + ) + + return query.order_by(Mileage.trip_date.desc()) + + +@mileage_bp.route("/mileage/export/csv") +@login_required +@module_enabled("mileage") +def export_mileage_csv(): + """Export (filtered) mileage entries as CSV. Uses same filters as list_mileage.""" + query = _mileage_export_query() + entries = query.all() + + settings = Settings.get_settings() + delimiter = getattr(settings, "export_delimiter", ",") or "," + output = io.StringIO() + writer = csv.writer(output, delimiter=delimiter) + + writer.writerow( + [ + "ID", + "Date", + "User", + "Purpose", + "Start Location", + "End Location", + "Distance (km)", + "Rate per km", + "Amount", + "Round Trip", + "Status", + "Project", + "Client", + "Notes", + ] + ) + + for entry in entries: + multiplier = 2 if entry.is_round_trip else 1 + amount = float(entry.calculated_amount or 0) * multiplier + writer.writerow( + [ + entry.id, + entry.trip_date.isoformat() if entry.trip_date else "", + (entry.user.display_name if entry.user else ""), + entry.purpose or "", + entry.start_location or "", + entry.end_location or "", + float(entry.distance_km or 0), + float(entry.rate_per_km or 0), + amount, + "Yes" if entry.is_round_trip else "No", + entry.status or "", + (entry.project.name if entry.project else ""), + (entry.client.name if entry.client else ""), + entry.notes or "", + ] + ) + + csv_bytes = output.getvalue().encode("utf-8") + start_part = request.args.get("start_date", "") or "all" + end_part = request.args.get("end_date", "") or "all" + filename = f"mileage_export_{start_part}_to_{end_part}.csv" + + return send_file( + io.BytesIO(csv_bytes), + mimetype="text/csv", + as_attachment=True, + download_name=filename, + ) + + +@mileage_bp.route("/mileage/export/pdf") +@login_required +@module_enabled("mileage") +def export_mileage_pdf(): + """Export (filtered) mileage entries as PDF. Uses same filters as list_mileage.""" + query = _mileage_export_query() + entries = query.all() + + start_date = request.args.get("start_date", "").strip() or None + end_date = request.args.get("end_date", "").strip() or None + + pdf_filters = {} + if request.args.get("status"): + pdf_filters["Status"] = request.args.get("status") + if request.args.get("project_id", type=int): + proj = Project.query.get(request.args.get("project_id", type=int)) + if proj: + pdf_filters["Project"] = proj.name + if request.args.get("client_id", type=int): + cli = Client.query.get(request.args.get("client_id", type=int)) + if cli: + pdf_filters["Client"] = cli.name + + try: + from app.utils.mileage_pdf import build_mileage_pdf + pdf_bytes = build_mileage_pdf( + entries, + start_date=start_date, + end_date=end_date, + filters=pdf_filters if pdf_filters else None, + ) + except Exception as e: + current_app.logger.warning("Mileage PDF export failed: %s", e, exc_info=True) + flash(_("PDF export failed: %(error)s", error=str(e)), "error") + return redirect(url_for("mileage.list_mileage")) + + start_part = start_date or "all" + end_part = end_date or "all" + filename = f"mileage_export_{start_part}_to_{end_part}.pdf" + + return send_file( + io.BytesIO(pdf_bytes), + mimetype="application/pdf", + as_attachment=True, + download_name=filename, + ) + + @mileage_bp.route("/mileage/create", methods=["GET", "POST"]) @login_required @module_enabled("mileage") diff --git a/app/routes/per_diem.py b/app/routes/per_diem.py index 9ec27e25..38ac1c80 100644 --- a/app/routes/per_diem.py +++ b/app/routes/per_diem.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, current_app from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db, log_event, track_event @@ -9,6 +9,8 @@ from app.utils.db import safe_commit from app.utils.permissions import admin_or_permission_required from app.utils.module_helpers import module_enabled +import csv +import io per_diem_bp = Blueprint("per_diem", __name__) @@ -19,6 +21,7 @@ def list_per_diem(): """List all per diem claims with filters""" from app import track_page_view + from app.utils.client_lock import enforce_locked_client_id track_page_view("per_diem_list") @@ -29,6 +32,7 @@ def list_per_diem(): status = request.args.get("status", "").strip() project_id = request.args.get("project_id", type=int) client_id = request.args.get("client_id", type=int) + client_id = enforce_locked_client_id(client_id) start_date = request.args.get("start_date", "").strip() end_date = request.args.get("end_date", "").strip() @@ -71,6 +75,8 @@ def list_per_diem(): # Get filter options projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.get_active_clients() + only_one_client = len(clients) == 1 + single_client = clients[0] if only_one_client else None # Calculate totals total_amount_query = db.session.query(db.func.sum(PerDiem.calculated_amount)).filter( @@ -91,6 +97,8 @@ def list_per_diem(): pagination=per_diem_pagination, projects=projects, clients=clients, + only_one_client=only_one_client, + single_client=single_client, total_amount=float(total_amount), currency=currency, status=status, @@ -101,6 +109,162 @@ def list_per_diem(): ) +def _per_diem_export_query(): + """Build the same filtered query as list_per_diem (no pagination).""" + from sqlalchemy.orm import joinedload + from app.utils.client_lock import enforce_locked_client_id + + status = request.args.get("status", "").strip() + project_id = request.args.get("project_id", type=int) + client_id = request.args.get("client_id", type=int) + client_id = enforce_locked_client_id(client_id) + start_date = request.args.get("start_date", "").strip() + end_date = request.args.get("end_date", "").strip() + + query = PerDiem.query.options( + joinedload(PerDiem.user), + joinedload(PerDiem.project), + joinedload(PerDiem.client), + ) + + if not current_user.is_admin: + query = query.filter(db.or_(PerDiem.user_id == current_user.id, PerDiem.approved_by == current_user.id)) + + if status: + query = query.filter(PerDiem.status == status) + if project_id: + query = query.filter(PerDiem.project_id == project_id) + if client_id: + query = query.filter(PerDiem.client_id == client_id) + if start_date: + try: + start = datetime.strptime(start_date, "%Y-%m-%d").date() + query = query.filter(PerDiem.start_date >= start) + except ValueError: + pass + if end_date: + try: + end = datetime.strptime(end_date, "%Y-%m-%d").date() + query = query.filter(PerDiem.end_date <= end) + except ValueError: + pass + + return query.order_by(PerDiem.start_date.desc()) + + +@per_diem_bp.route("/per-diem/export/csv") +@login_required +@module_enabled("per_diem") +def export_per_diem_csv(): + """Export (filtered) per diem claims as CSV. Uses same filters as list_per_diem.""" + query = _per_diem_export_query() + entries = query.all() + + settings = Settings.get_settings() + delimiter = getattr(settings, "export_delimiter", ",") or "," + output = io.StringIO() + writer = csv.writer(output, delimiter=delimiter) + + writer.writerow( + [ + "ID", + "User", + "Trip Purpose", + "Start Date", + "End Date", + "Country", + "City", + "Full Days", + "Half Days", + "Amount", + "Status", + "Project", + "Client", + "Notes", + ] + ) + + for entry in entries: + writer.writerow( + [ + entry.id, + (entry.user.display_name if entry.user else ""), + entry.trip_purpose or "", + entry.start_date.isoformat() if entry.start_date else "", + entry.end_date.isoformat() if entry.end_date else "", + entry.country or "", + entry.city or "", + entry.full_days or 0, + entry.half_days or 0, + float(entry.calculated_amount or 0), + entry.status or "", + (entry.project.name if entry.project else ""), + (entry.client.name if entry.client else ""), + entry.notes or "", + ] + ) + + csv_bytes = output.getvalue().encode("utf-8") + start_part = request.args.get("start_date", "") or "all" + end_part = request.args.get("end_date", "") or "all" + filename = f"per_diem_export_{start_part}_to_{end_part}.csv" + + return send_file( + io.BytesIO(csv_bytes), + mimetype="text/csv", + as_attachment=True, + download_name=filename, + ) + + +@per_diem_bp.route("/per-diem/export/pdf") +@login_required +@module_enabled("per_diem") +def export_per_diem_pdf(): + """Export (filtered) per diem claims as PDF. Uses same filters as list_per_diem.""" + query = _per_diem_export_query() + entries = query.all() + + start_date = request.args.get("start_date", "").strip() or None + end_date = request.args.get("end_date", "").strip() or None + + pdf_filters = {} + if request.args.get("status"): + pdf_filters["Status"] = request.args.get("status") + if request.args.get("project_id", type=int): + proj = Project.query.get(request.args.get("project_id", type=int)) + if proj: + pdf_filters["Project"] = proj.name + if request.args.get("client_id", type=int): + cli = Client.query.get(request.args.get("client_id", type=int)) + if cli: + pdf_filters["Client"] = cli.name + + try: + from app.utils.per_diem_pdf import build_per_diem_pdf + pdf_bytes = build_per_diem_pdf( + entries, + start_date=start_date, + end_date=end_date, + filters=pdf_filters if pdf_filters else None, + ) + except Exception as e: + current_app.logger.warning("Per diem PDF export failed: %s", e, exc_info=True) + flash(_("PDF export failed: %(error)s", error=str(e)), "error") + return redirect(url_for("per_diem.list_per_diem")) + + start_part = start_date or "all" + end_part = end_date or "all" + filename = f"per_diem_export_{start_part}_to_{end_part}.pdf" + + return send_file( + io.BytesIO(pdf_bytes), + mimetype="application/pdf", + as_attachment=True, + download_name=filename, + ) + + @per_diem_bp.route("/per-diem/create", methods=["GET", "POST"]) @login_required @module_enabled("per_diem") diff --git a/app/templates/mileage/list.html b/app/templates/mileage/list.html index a7fd5e2e..a3ed4553 100644 --- a/app/templates/mileage/list.html +++ b/app/templates/mileage/list.html @@ -55,11 +55,20 @@
-
+

{{ _('Filter Mileage') }}

- +
+ {{ _('Exports use current filters') }} + + {{ _('Export CSV') }} + + + {{ _('Export PDF') }} + + +
@@ -250,6 +259,57 @@

.filter-toggle-transition { transition: all 0.3s ease-in-out; }