Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ceae30e
Fix #563: correct route for post-timer toast after Stop & Save
evilguy4000 Mar 11, 2026
147da29
Fix(web): prevent mobile browser freeze on Log Time page (Issue #557)
evilguy4000 Mar 11, 2026
de2a7db
fix: restrict subcontractors to assigned projects/clients when starti…
evilguy4000 Mar 11, 2026
ca0c181
feat(overtime): add get_overtime_ytd and get_overtime_last_12_months …
evilguy4000 Mar 11, 2026
bd31609
feat(overtime): show accumulated overtime (YTD) on dashboard and in A…
evilguy4000 Mar 11, 2026
251d41b
feat(workforce): overtime overview and take as paid leave (Issue #560)
evilguy4000 Mar 11, 2026
60551f3
feat(migration): seed Overtime leave type for take-as-paid-leave (Iss…
evilguy4000 Mar 11, 2026
eefb529
test(overtime): YTD helpers and overtime-as-leave validation (Issue #…
evilguy4000 Mar 11, 2026
1eadcd0
docs(overtime): accumulated YTD and take as paid leave (Issue #560)
evilguy4000 Mar 11, 2026
f66d5b7
feat(break-time): add migrations for break_seconds, paused_at, and de…
evilguy4000 Mar 11, 2026
8752d3d
feat(break-time): add break_seconds and pause support to TimeEntry an…
evilguy4000 Mar 11, 2026
7813f3f
feat(break-time): add pause_timer/resume_timer and break_seconds to s…
evilguy4000 Mar 11, 2026
cef83ff
feat(break-time): add pause/resume routes, timer status, manual and e…
evilguy4000 Mar 11, 2026
c36736d
feat(break-time): add Pause/Resume and break UI (Issue #561)
evilguy4000 Mar 11, 2026
a70285b
docs: add break time feature documentation and changelog (Issue #561)
evilguy4000 Mar 11, 2026
daf3236
feat(workforce): add delete for periods, time-off, leave types, and h…
evilguy4000 Mar 11, 2026
1ad8998
fix(time-entries): apply date filter and export by current filters (I…
evilguy4000 Mar 11, 2026
1d3a154
feat(mileage,per_diem): add CSV/PDF export and filter-aware export (I…
evilguy4000 Mar 11, 2026
b50ce51
fix: resolve duplicate timer.resume_timer endpoint on startup
evilguy4000 Mar 11, 2026
8f1cfd2
Update setup.py
evilguy4000 Mar 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ 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.

### 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.
- **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.
Expand Down
6 changes: 6 additions & 0 deletions app/models/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 58 additions & 10 deletions app/models/time_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -55,6 +57,7 @@ def __init__(
paid=False,
invoice_number=None,
duration_seconds=None,
break_seconds=None,
**kwargs,
):
"""Initialize a TimeEntry instance.
Expand All @@ -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:
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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)."""
Expand All @@ -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"):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions app/repositories/time_entry_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,15 @@ 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,
billable: bool = True,
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,
Expand All @@ -180,15 +181,14 @@ 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,
paid=paid,
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)
Expand Down
30 changes: 23 additions & 7 deletions app/routes/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand Down
17 changes: 14 additions & 3 deletions app/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -1508,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(
Expand All @@ -1524,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"],
}
)

Expand Down Expand Up @@ -1825,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)
Expand All @@ -1837,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(
Expand All @@ -1853,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"],
}
)

Expand Down
Loading
Loading