Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,6 @@ Replace `your-domain.com` with your TimeTracker host and `YOUR_API_TOKEN` with y
## Full Documentation

- **[REST API reference](docs/api/REST_API.md)** — All endpoints, request/response formats, pagination, errors
- **[API Consistency Audit](docs/api/API_CONSISTENCY_AUDIT.md)** — Response contracts, error format, pagination
- **[API Token Scopes](docs/api/API_TOKEN_SCOPES.md)** — Scopes and permissions
- **[API Versioning](docs/api/API_VERSIONING.md)** — Versioning policy and usage
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Thank you for your interest in contributing to TimeTracker. This page gives you

For development setup, coding standards, testing, pull request process, and commit conventions, see:

- **[Contributor Guide](docs/development/CONTRIBUTOR_GUIDE.md)** — Architecture, local dev, testing, how to add routes/services/templates, versioning
- **[Contributing guidelines (full)](docs/development/CONTRIBUTING.md)** — Development setup, coding standards, testing, PR process
- **[Code of Conduct](docs/development/CODE_OF_CONDUCT.md)** — Community standards and expected behavior
- **[CHANGELOG.md](CHANGELOG.md)** — How we track changes; update the *Unreleased* section for user-facing changes
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# TimeTracker Development Guide

Quick reference for running the project locally, running tests, and contributing. For full guidelines, see [Contributing](CONTRIBUTING.md) and the [developer documentation](docs/development/CONTRIBUTING.md).
Quick reference for running the project locally, running tests, and contributing. For a single-page contributor overview (workflows, adding routes/services/templates), see [Contributor Guide](docs/development/CONTRIBUTOR_GUIDE.md). For full guidelines, see [Contributing](CONTRIBUTING.md) and the [developer documentation](docs/development/CONTRIBUTING.md).

## Running Locally

Expand Down
16 changes: 4 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ TimeTracker is built with modern, reliable technologies:

## 🖥️ UI overview

The web app uses a **single main layout** with a sidebar and top header. Content is centered with a max width for readability. **Getting around:** **Dashboard** — overview, today’s stats, and the main **Timer** widget (start/stop, quick start, repeat last). **Timer** and **Time entries** are first-class in the sidebar for fast access. **Time entries** is the place to filter, review, and export all logged time. **Reports**, **Projects**, **Finance**, and **Settings** are available from the sidebar and navigation. For design and component conventions, see [UI Guidelines](docs/UI_GUIDELINES.md).
The web app uses a **single main layout** with a sidebar and top header. Content is centered with a max width for readability. **Getting around:** **Dashboard** — overview, today’s stats, and the main **Timer** widget (start/stop, quick start, repeat last). **Timer** and **Time entries** are first-class in the sidebar for fast access. **Time entries** is the place to filter, review, and export all logged time. **Reports** (time, project, finance) are available from the sidebar (top-level **Reports** link or **Finance & Expenses → Reports** for Report Builder, Saved Views, Scheduled Reports), and from the bottom bar on mobile. **Projects**, **Finance**, and **Settings** are available from the sidebar and navigation. For design and component conventions, see [UI Guidelines](docs/UI_GUIDELINES.md).

---

Expand All @@ -86,20 +86,12 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh

> **📋 For complete release history, see [CHANGELOG.md](CHANGELOG.md)**

**Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for release history.
**Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for versioned release history.
- 📱 **Native Mobile & Desktop Apps** — Flutter mobile app (iOS/Android) and Electron desktop app with time tracking, offline support, and API integration ([Build Guide](BUILD.md), [Docs](docs/mobile-desktop-apps/README.md))
- 📋 **Project Analysis & Documentation** — Comprehensive project analysis and documentation updates
- 🔧 **Version Consistency** — Fixed version inconsistencies across documentation files

**Previous Releases:**
- **v4.14.0** (January 2025) — Documentation and technology stack updates
- **v4.6.0** (December 2025) — Comprehensive Issue/Bug Tracking System

**Recent Releases:**
- **v4.5.1** — Performance optimizations and version management improvements
- **v4.5.0** — Advanced Report Builder, quick task creation, Kanban enhancements, and PWA improvements
- **v4.4.1** — Dashboard cache fixes and custom reports enhancements
- **v4.4.0** — Project custom fields, file attachments, and salesman-based report splitting
See [CHANGELOG.md](CHANGELOG.md) for all release notes and version history.

### 🎯 **Major Feature Additions**

Expand Down Expand Up @@ -437,7 +429,7 @@ docker-compose up -d
# Click "Advanced" → "Proceed to localhost" to continue
```

**First login creates the admin account** — just enter your username!
**First login creates the admin account** — just enter your username! For setup problems, see [INSTALLATION.md](INSTALLATION.md).

**📖 See the complete setup guide:** [`docs/admin/configuration/DOCKER_COMPOSE_SETUP.md`](docs/admin/configuration/DOCKER_COMPOSE_SETUP.md)

Expand Down
8 changes: 8 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,14 @@ def record_metrics_and_log(response):
except Exception as e:
app.logger.warning(f"Could not enable query logging: {e}")

# Optional performance instrumentation (slow-request log, query-count when PERF_QUERY_PROFILE=1)
try:
from app.utils.performance import init_performance_logging

init_performance_logging(app)
except Exception as e:
app.logger.warning(f"Could not init performance logging: {e}")

# Load analytics configuration (embedded at build time)
from app.config.analytics_defaults import get_analytics_config, has_analytics_configured

Expand Down
6 changes: 6 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ class Config:
"Referrer-Policy": "strict-origin-when-cross-origin",
}

# Performance instrumentation (optional; no production overhead when disabled)
# Log a single line when request duration exceeds this many milliseconds (0 = disabled)
PERF_LOG_SLOW_REQUESTS_MS = int(os.getenv("PERF_LOG_SLOW_REQUESTS_MS", "0"))
# When true, track DB query count per request and include in slow-request logs
PERF_QUERY_PROFILE = os.getenv("PERF_QUERY_PROFILE", "false").lower() == "true"

# Rate limiting
RATELIMIT_DEFAULT = os.getenv("RATELIMIT_DEFAULT", "") # e.g., "200 per day;50 per hour"
RATELIMIT_STORAGE_URI = os.getenv("RATELIMIT_STORAGE_URI", "memory://")
Expand Down
115 changes: 4 additions & 111 deletions app/models/recurring_invoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,117 +111,10 @@ def should_generate_today(self):
return today >= self.next_run_date

def generate_invoice(self):
"""Generate an invoice from this recurring template"""
from app.models import Invoice, InvoiceItem, TimeEntry, Settings

if not self.should_generate_today():
return None

# Get settings for currency
settings = Settings.get_settings()
currency_code = self.currency_code or (settings.currency if settings else "EUR")

# Calculate dates
issue_date = datetime.utcnow().date()
due_date = issue_date + timedelta(days=self.due_date_days)

# Generate invoice number
invoice_number = Invoice.generate_invoice_number()

# Create invoice
invoice = Invoice(
invoice_number=invoice_number,
project_id=self.project_id,
client_name=self.client_name,
due_date=due_date,
created_by=self.created_by,
client_id=self.client_id,
client_email=self.client_email,
client_address=self.client_address,
tax_rate=self.tax_rate,
notes=self.notes,
terms=self.terms,
currency_code=currency_code,
template_id=self.template_id,
issue_date=issue_date,
)

# Link to recurring invoice template
invoice.recurring_invoice_id = self.id

db.session.add(invoice)

# Auto-include time entries if enabled
if self.auto_include_time_entries:
# Get unbilled time entries for this project
time_entries = (
TimeEntry.query.filter(
TimeEntry.project_id == self.project_id, TimeEntry.end_time.isnot(None), TimeEntry.billable == True
)
.order_by(TimeEntry.start_time.desc())
.all()
)

# Filter out entries already billed
unbilled_entries = []
for entry in time_entries:
already_billed = False
for other_invoice in self.project.invoices:
if other_invoice.id != invoice.id:
for item in other_invoice.items:
if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(","):
already_billed = True
break
if already_billed:
break

if not already_billed:
unbilled_entries.append(entry)

# Group and create invoice items
if unbilled_entries:
from app.models.rate_override import RateOverride

grouped_entries = {}
for entry in unbilled_entries:
if entry.task_id:
key = f"task_{entry.task_id}"
description = f"Task: {entry.task.name if entry.task else 'Unknown Task'}"
else:
key = f"project_{entry.project_id}"
description = f"Project: {entry.project.name}"

if key not in grouped_entries:
grouped_entries[key] = {
"description": description,
"entries": [],
"total_hours": Decimal("0"),
}

grouped_entries[key]["entries"].append(entry)
grouped_entries[key]["total_hours"] += entry.duration_hours

# Create invoice items
hourly_rate = RateOverride.resolve_rate(self.project)
for group in grouped_entries.values():
if group["total_hours"] > 0:
item = InvoiceItem(
invoice_id=invoice.id,
description=group["description"],
quantity=group["total_hours"],
unit_price=hourly_rate,
time_entry_ids=",".join(str(e.id) for e in group["entries"]),
)
db.session.add(item)

# Calculate totals
invoice.calculate_totals()

# Update recurring invoice
self.last_generated_at = datetime.utcnow()
self.next_run_date = self.calculate_next_run_date(issue_date)

return invoice
"""Generate an invoice from this recurring template. Delegates to RecurringInvoiceService."""
from app.services.recurring_invoice_service import RecurringInvoiceService

return RecurringInvoiceService().generate_invoice(self)

def to_dict(self):
"""Convert recurring invoice to dictionary"""
Expand Down
2 changes: 2 additions & 0 deletions app/repositories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .expense_repository import ExpenseRepository
from .payment_repository import PaymentRepository
from .comment_repository import CommentRepository
from .recurring_invoice_repository import RecurringInvoiceRepository

__all__ = [
"TimeEntryRepository",
Expand All @@ -24,4 +25,5 @@
"ExpenseRepository",
"PaymentRepository",
"CommentRepository",
"RecurringInvoiceRepository",
]
31 changes: 31 additions & 0 deletions app/repositories/recurring_invoice_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""
Repository for recurring invoice data access.
"""

from typing import List, Optional

from app.models import RecurringInvoice
from app.repositories.base_repository import BaseRepository


class RecurringInvoiceRepository(BaseRepository[RecurringInvoice]):
"""Repository for RecurringInvoice operations."""

def __init__(self):
super().__init__(RecurringInvoice)

def list_for_user(
self,
created_by: Optional[int] = None,
is_admin: bool = False,
is_active: Optional[bool] = None,
) -> List[RecurringInvoice]:
"""List recurring invoices, optionally filtered by creator and active status."""
query = self.model.query
if not is_admin and created_by is not None:
query = query.filter_by(created_by=created_by)
if is_active is True:
query = query.filter_by(is_active=True)
elif is_active is False:
query = query.filter_by(is_active=False)
return query.order_by(RecurringInvoice.next_run_date.asc()).all()
72 changes: 72 additions & 0 deletions app/repositories/time_entry_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ def get_by_date_range(

return query.order_by(TimeEntry.start_time.desc()).all()

def count_for_date_range(
self,
start_date: datetime,
end_date: datetime,
user_id: Optional[int] = None,
project_id: Optional[int] = None,
client_id: Optional[int] = None,
) -> int:
"""Count time entries in date range with optional filters (avoids loading all rows)."""
from sqlalchemy import func

query = db.session.query(func.count(TimeEntry.id)).filter(
and_(TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date)
)
if user_id:
query = query.filter_by(user_id=user_id)
if project_id:
query = query.filter_by(project_id=project_id)
if client_id:
query = query.filter_by(client_id=client_id)
result = query.scalar()
return int(result) if result else 0

def get_billable_entries(
self,
user_id: Optional[int] = None,
Expand Down Expand Up @@ -194,6 +217,17 @@ def create_manual_entry(
db.session.add(entry)
return entry

def get_distinct_project_ids_for_user(self, user_id: int) -> List[int]:
"""Return distinct project IDs the user has time entries for (excludes None)."""
rows = (
self.model.query.with_entities(TimeEntry.project_id)
.filter_by(user_id=user_id)
.filter(TimeEntry.project_id.isnot(None))
.distinct()
.all()
)
return [r[0] for r in rows]

def get_total_duration(
self,
user_id: Optional[int] = None,
Expand Down Expand Up @@ -228,3 +262,41 @@ def get_total_duration(

result = query.scalar()
return int(result) if result else 0

def get_task_aggregates(
self,
task_ids: List[int],
start_date: datetime,
end_date: datetime,
project_id: Optional[int] = None,
user_id: Optional[int] = None,
) -> List[tuple]:
"""
Return (task_id, total_seconds, entry_count) for each task in task_ids,
filtered by date range and optional project_id/user_id.
Use for task report to avoid N+1 per-task queries.
"""
if not task_ids:
return []
from sqlalchemy import func

query = (
db.session.query(
TimeEntry.task_id,
func.sum(TimeEntry.duration_seconds).label("total_seconds"),
func.count(TimeEntry.id).label("entry_count"),
)
.filter(
TimeEntry.task_id.in_(task_ids),
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_date,
TimeEntry.start_time <= end_date,
)
.group_by(TimeEntry.task_id)
)
if project_id:
query = query.filter(TimeEntry.project_id == project_id)
if user_id:
query = query.filter(TimeEntry.user_id == user_id)
rows = query.all()
return [(r.task_id, int(r.total_seconds or 0), r.entry_count) for r in rows]
Loading
Loading