From 88e8f7c6197f81c2f6f027be7ca69850738ff663 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:49:30 +0200 Subject: [PATCH 01/21] feat: add models and migrations for product gap backlog Introduce schema for Pomodoro prefs, payroll templates, sync errors, ActivityWatch rules, recurring costs, share links, and geofences. --- app/models/__init__.py | 14 +++ app/models/activitywatch_rule.py | 111 +++++++++++++++++ app/models/geofence.py | 55 +++++++++ app/models/integration_sync_error.py | 45 +++++++ app/models/payroll_export_template.py | 75 ++++++++++++ app/models/recurring_project_cost.py | 112 ++++++++++++++++++ app/models/shared_report_link.py | 102 ++++++++++++++++ app/models/user.py | 6 + app/models/workday_session.py | 11 ++ .../185_add_pomodoro_user_defaults.py | 51 ++++++++ .../186_add_payroll_export_templates.py | 41 +++++++ .../187_add_integration_sync_errors.py | 43 +++++++ .../versions/188_add_activitywatch_rules.py | 71 +++++++++++ .../189_add_recurring_project_costs.py | 57 +++++++++ .../versions/190_add_shared_report_links.py | 56 +++++++++ migrations/versions/191_add_geofences.py | 78 ++++++++++++ 16 files changed, 928 insertions(+) create mode 100644 app/models/activitywatch_rule.py create mode 100644 app/models/geofence.py create mode 100644 app/models/integration_sync_error.py create mode 100644 app/models/payroll_export_template.py create mode 100644 app/models/recurring_project_cost.py create mode 100644 app/models/shared_report_link.py create mode 100644 migrations/versions/185_add_pomodoro_user_defaults.py create mode 100644 migrations/versions/186_add_payroll_export_templates.py create mode 100644 migrations/versions/187_add_integration_sync_errors.py create mode 100644 migrations/versions/188_add_activitywatch_rules.py create mode 100644 migrations/versions/189_add_recurring_project_costs.py create mode 100644 migrations/versions/190_add_shared_report_links.py create mode 100644 migrations/versions/191_add_geofences.py diff --git a/app/models/__init__.py b/app/models/__init__.py index cc6f6a734..1e963ebd8 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,4 +1,5 @@ from .activity import Activity +from .activitywatch_rule import ActivityWatchRule, PendingActivity from .api_idempotency_key import ApiIdempotencyKey from .api_token import ApiToken from .attendance_compliance import ( @@ -38,10 +39,12 @@ from .expense_gps import MileageTrack from .extra_good import ExtraGood from .focus_session import FocusSession +from .geofence import Geofence, GeofencePolicy from .gamification import Badge, Leaderboard, LeaderboardEntry, UserBadge from .import_export import DataExport, DataImport from .integration import Integration, IntegrationCredential, IntegrationEvent from .integration_external_event_link import IntegrationExternalEventLink +from .integration_sync_error import IntegrationSyncError from .invoice import Invoice, InvoiceItem from .invoice_approval import InvoiceApproval from .invoice_email import InvoiceEmail @@ -58,6 +61,7 @@ from .mileage import Mileage from .payment_gateway import PaymentGateway, PaymentTransaction from .payments import CreditNote, InvoiceReminderSchedule, Payment +from .payroll_export_template import PayrollExportTemplate from .per_diem import PerDiem, PerDiemRate from .permission import Permission, Role from .project import Project @@ -75,8 +79,10 @@ from .rate_override import RateOverride from .recurring_block import RecurringBlock from .recurring_invoice import RecurringInvoice +from .recurring_project_cost import RecurringProjectCost from .recurring_task import RecurringTask from .reporting import ReportEmailSchedule, SavedReportView +from .shared_report_link import SharedReportLink from .salesman_email_mapping import SalesmanEmailMapping from .saved_filter import SavedFilter from .settings import Settings @@ -127,6 +133,8 @@ "TaskChecklistItem", "Comment", "FocusSession", + "Geofence", + "GeofencePolicy", "RecurringBlock", "RateOverride", "SavedFilter", @@ -136,14 +144,18 @@ "ExchangeRate", "TaxRule", "Payment", + "PayrollExportTemplate", "CreditNote", "InvoiceReminderSchedule", "SavedReportView", + "SharedReportLink", "ReportEmailSchedule", "KanbanColumn", "KanbanBoardTemplate", "TimeEntryTemplate", "Activity", + "ActivityWatchRule", + "PendingActivity", "UserFavoriteProject", "UserClient", "ClientNote", @@ -170,6 +182,7 @@ "ClientPrepaidConsumption", "AuditLog", "RecurringInvoice", + "RecurringProjectCost", "InvoiceEmail", "InvoicePeppolTransmission", "Webhook", @@ -211,6 +224,7 @@ "CalendarIntegration", "CalendarSyncEvent", "Integration", + "IntegrationSyncError", "IntegrationCredential", "IntegrationEvent", "IntegrationExternalEventLink", diff --git a/app/models/activitywatch_rule.py b/app/models/activitywatch_rule.py new file mode 100644 index 000000000..576f7302a --- /dev/null +++ b/app/models/activitywatch_rule.py @@ -0,0 +1,111 @@ +"""ActivityWatch mapping rules and pending review queue.""" + +import re +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from app import db + + +class ActivityWatchRule(db.Model): + __tablename__ = "activitywatch_rules" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + pattern_type = db.Column(db.String(30), nullable=False, default="app") # app|domain|title_regex|url_regex + pattern_value = db.Column(db.String(500), nullable=False) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True) + task_id = db.Column(db.Integer, db.ForeignKey("tasks.id"), nullable=True) + tags = db.Column(db.JSON, nullable=True) + billable = db.Column(db.Boolean, nullable=False, default=True) + min_duration_seconds = db.Column(db.Integer, nullable=True) + priority = db.Column(db.Integer, nullable=False, default=100) + is_active = db.Column(db.Boolean, nullable=False, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + def to_dict(self): + return { + "id": self.id, + "user_id": self.user_id, + "name": self.name, + "pattern_type": self.pattern_type, + "pattern_value": self.pattern_value, + "project_id": self.project_id, + "task_id": self.task_id, + "tags": self.tags or [], + "billable": self.billable, + "min_duration_seconds": self.min_duration_seconds, + "priority": self.priority, + "is_active": self.is_active, + } + + def matches(self, *, app: str = "", title: str = "", url: str = "") -> bool: + value = (self.pattern_value or "").strip() + if not value: + return False + ptype = (self.pattern_type or "app").lower() + try: + if ptype == "app": + return value.lower() in (app or "").lower() + if ptype == "domain": + return value.lower() in (url or "").lower() + if ptype == "title_regex": + return bool(re.search(value, title or "", re.IGNORECASE)) + if ptype == "url_regex": + return bool(re.search(value, url or "", re.IGNORECASE)) + except re.error: + return False + return False + + @classmethod + def match_event(cls, user_id: int, app: str, title: str, url: str) -> Optional["ActivityWatchRule"]: + rules = ( + cls.query.filter_by(user_id=user_id, is_active=True) + .order_by(cls.priority.asc(), cls.id.asc()) + .all() + ) + for rule in rules: + if rule.matches(app=app, title=title, url=url): + return rule + return None + + +class PendingActivity(db.Model): + __tablename__ = "pending_activities" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + aw_bucket = db.Column(db.String(255), nullable=True) + app_name = db.Column(db.String(255), nullable=True) + title = db.Column(db.String(500), nullable=True) + url = db.Column(db.String(1000), nullable=True) + started_at = db.Column(db.DateTime, nullable=False) + duration_seconds = db.Column(db.Integer, nullable=False, default=0) + matched_rule_id = db.Column(db.Integer, db.ForeignKey("activitywatch_rules.id"), nullable=True) + suggested_project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True) + suggested_task_id = db.Column(db.Integer, db.ForeignKey("tasks.id"), nullable=True) + suggested_billable = db.Column(db.Boolean, nullable=False, default=True) + external_uid = db.Column(db.String(255), nullable=True, index=True) + status = db.Column(db.String(20), nullable=False, default="pending", index=True) # pending|approved|discarded + notes = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + def to_dict(self): + return { + "id": self.id, + "user_id": self.user_id, + "aw_bucket": self.aw_bucket, + "app_name": self.app_name, + "title": self.title, + "url": self.url, + "started_at": self.started_at.isoformat() if self.started_at else None, + "duration_seconds": self.duration_seconds, + "matched_rule_id": self.matched_rule_id, + "suggested_project_id": self.suggested_project_id, + "suggested_task_id": self.suggested_task_id, + "suggested_billable": self.suggested_billable, + "status": self.status, + "notes": self.notes, + } diff --git a/app/models/geofence.py b/app/models/geofence.py new file mode 100644 index 000000000..ad6e21826 --- /dev/null +++ b/app/models/geofence.py @@ -0,0 +1,55 @@ +"""Geofence definitions for field attendance policies.""" + +from datetime import datetime + +from app import db + + +class GeofencePolicy: + LOG = "log" + WARN = "warn" + BLOCK = "block" + + CHOICES = (LOG, WARN, BLOCK) + + +class Geofence(db.Model): + __tablename__ = "geofences" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + lat = db.Column(db.Float, nullable=False) + lng = db.Column(db.Float, nullable=False) + radius_m = db.Column(db.Float, nullable=False, default=100.0) + address = db.Column(db.String(500), nullable=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) + is_active = db.Column(db.Boolean, nullable=False, default=True) + policy = db.Column(db.String(10), nullable=False, default=GeofencePolicy.LOG) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + project = db.relationship("Project", backref=db.backref("geofences", lazy="dynamic")) + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "lat": self.lat, + "lng": self.lng, + "radius_m": self.radius_m, + "address": self.address, + "project_id": self.project_id, + "project_name": self.project.name if self.project else None, + "is_active": self.is_active, + "policy": self.policy, + } + + @classmethod + def active_query(cls, project_id=None): + """Active geofences applicable to a workday context.""" + query = cls.query.filter_by(is_active=True) + if project_id is None: + query = query.filter(cls.project_id.is_(None)) + else: + query = query.filter(db.or_(cls.project_id.is_(None), cls.project_id == project_id)) + return query.order_by(cls.name.asc()) diff --git a/app/models/integration_sync_error.py b/app/models/integration_sync_error.py new file mode 100644 index 000000000..eaed8142e --- /dev/null +++ b/app/models/integration_sync_error.py @@ -0,0 +1,45 @@ +"""Dead-letter queue for integration sync failures.""" + +from datetime import datetime + +from app import db + + +class IntegrationSyncError(db.Model): + __tablename__ = "integration_sync_errors" + + id = db.Column(db.Integer, primary_key=True) + connector = db.Column(db.String(50), nullable=False, index=True) + entity_type = db.Column(db.String(50), nullable=False, index=True) + entity_id = db.Column(db.Integer, nullable=True, index=True) + error_message = db.Column(db.Text, nullable=False) + retry_count = db.Column(db.Integer, nullable=False, default=0) + resolved = db.Column(db.Boolean, nullable=False, default=False, index=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + last_retry_at = db.Column(db.DateTime, nullable=True) + + def to_dict(self): + return { + "id": self.id, + "connector": self.connector, + "entity_type": self.entity_type, + "entity_id": self.entity_id, + "error_message": self.error_message, + "retry_count": self.retry_count, + "resolved": self.resolved, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "last_retry_at": self.last_retry_at.isoformat() if self.last_retry_at else None, + } + + @classmethod + def record(cls, connector: str, entity_type: str, entity_id, error_message: str): + row = cls( + connector=connector, + entity_type=entity_type, + entity_id=int(entity_id) if entity_id is not None else None, + error_message=(error_message or "")[:4000], + ) + db.session.add(row) + return row diff --git a/app/models/payroll_export_template.py b/app/models/payroll_export_template.py new file mode 100644 index 000000000..41bd67e5b --- /dev/null +++ b/app/models/payroll_export_template.py @@ -0,0 +1,75 @@ +"""Payroll export template presets for workforce CSV/XLSX downloads.""" + +from datetime import datetime + +from app import db + + +class PayrollExportTemplate(db.Model): + __tablename__ = "payroll_export_templates" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + columns = db.Column(db.JSON, nullable=False, default=list) + grouping = db.Column(db.String(20), nullable=False, default="week") # week|day|project + filters = db.Column(db.JSON, nullable=True, default=dict) + format = db.Column(db.String(10), nullable=False, default="csv") # csv|xlsx + delimiter = db.Column(db.String(5), nullable=False, default=",") + is_default = db.Column(db.Boolean, nullable=False, default=False) + is_builtin = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + DEFAULT_COLUMNS = [ + "user_id", + "username", + "employee_id", + "week_year", + "week_number", + "period_start", + "period_end", + "project_code", + "hours", + "billable_hours", + "non_billable_hours", + "rate", + "amount", + "earning_code", + "cost_center", + ] + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "columns": self.columns or [], + "grouping": self.grouping, + "filters": self.filters or {}, + "format": self.format, + "delimiter": self.delimiter, + "is_default": self.is_default, + "is_builtin": self.is_builtin, + } + + @classmethod + def ensure_builtin_defaults(cls): + """Seed the Generic Payroll preset if missing.""" + existing = cls.query.filter_by(name="Generic Payroll").first() + if existing: + return existing + # Clear other defaults if any + for t in cls.query.filter_by(is_default=True).all(): + t.is_default = False + tmpl = cls( + name="Generic Payroll", + columns=list(cls.DEFAULT_COLUMNS), + grouping="week", + filters={}, + format="csv", + delimiter=",", + is_default=True, + is_builtin=True, + ) + db.session.add(tmpl) + db.session.commit() + return tmpl diff --git a/app/models/recurring_project_cost.py b/app/models/recurring_project_cost.py new file mode 100644 index 000000000..de16f4d6c --- /dev/null +++ b/app/models/recurring_project_cost.py @@ -0,0 +1,112 @@ +from datetime import datetime, timedelta +from decimal import Decimal + +from dateutil.relativedelta import relativedelta + +from app import db + + +class RecurringProjectCost(db.Model): + """Recurring project cost template for automated expense generation.""" + + __tablename__ = "recurring_project_costs" + + id = db.Column(db.Integer, primary_key=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + + description = db.Column(db.String(500), nullable=False) + category = db.Column(db.String(50), nullable=False) + amount = db.Column(db.Numeric(10, 2), nullable=False) + billable = db.Column(db.Boolean, nullable=False, default=True) + currency_code = db.Column(db.String(3), nullable=False, default="EUR") + + frequency = db.Column(db.String(20), nullable=False) # daily, weekly, monthly, yearly + interval = db.Column(db.Integer, nullable=False, default=1) + next_run_date = db.Column(db.Date, nullable=False, index=True) + end_date = db.Column(db.Date, nullable=True) + is_active = db.Column(db.Boolean, nullable=False, default=True, index=True) + last_generated_at = db.Column(db.DateTime, nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + project = db.relationship("Project", backref=db.backref("recurring_costs", lazy="dynamic")) + user = db.relationship("User", backref=db.backref("recurring_project_costs", lazy="dynamic")) + + def __init__( + self, + project_id, + user_id, + description, + category, + amount, + frequency, + next_run_date, + **kwargs, + ): + self.project_id = project_id + self.user_id = user_id + self.description = description.strip() if description else "" + self.category = category + self.amount = Decimal(str(amount)) + self.frequency = frequency + self.next_run_date = next_run_date + self.interval = kwargs.get("interval", 1) + self.end_date = kwargs.get("end_date") + self.billable = kwargs.get("billable", True) + self.currency_code = kwargs.get("currency_code", "EUR") + self.is_active = kwargs.get("is_active", True) + + def __repr__(self): + return f"" + + def calculate_next_run_date(self, from_date=None): + if from_date is None: + from_date = datetime.utcnow().date() + + if self.frequency == "daily": + return from_date + timedelta(days=self.interval) + if self.frequency == "weekly": + return from_date + timedelta(weeks=self.interval) + if self.frequency == "monthly": + return from_date + relativedelta(months=self.interval) + if self.frequency == "yearly": + return from_date + relativedelta(years=self.interval) + raise ValueError(f"Invalid frequency: {self.frequency}") + + def should_generate_today(self): + if not self.is_active: + return False + + today = datetime.utcnow().date() + if self.end_date and today > self.end_date: + return False + return today >= self.next_run_date + + def generate_cost(self): + from app.services.recurring_project_cost_service import RecurringProjectCostService + + return RecurringProjectCostService().generate_cost(self) + + def to_dict(self): + return { + "id": self.id, + "project_id": self.project_id, + "user_id": self.user_id, + "description": self.description, + "category": self.category, + "amount": float(self.amount), + "billable": self.billable, + "currency_code": self.currency_code, + "frequency": self.frequency, + "interval": self.interval, + "next_run_date": self.next_run_date.isoformat() if self.next_run_date else None, + "end_date": self.end_date.isoformat() if self.end_date else None, + "is_active": self.is_active, + "last_generated_at": self.last_generated_at.isoformat() if self.last_generated_at else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "project": self.project.name if self.project else None, + "user": self.user.username if self.user else None, + } diff --git a/app/models/shared_report_link.py b/app/models/shared_report_link.py new file mode 100644 index 000000000..aab75b149 --- /dev/null +++ b/app/models/shared_report_link.py @@ -0,0 +1,102 @@ +import json +import uuid +from datetime import datetime + +from werkzeug.security import check_password_hash, generate_password_hash + +from app import db + + +class SharedReportLink(db.Model): + """Public shareable link for a saved report configuration snapshot.""" + + __tablename__ = "shared_report_links" + + id = db.Column(db.Integer, primary_key=True) + token = db.Column(db.String(36), unique=True, nullable=False, index=True) + saved_view_id = db.Column(db.Integer, db.ForeignKey("saved_report_views.id"), nullable=False, index=True) + report_name = db.Column(db.String(120), nullable=True) + report_config_snapshot = db.Column(db.Text, nullable=False) + created_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + expires_at = db.Column(db.DateTime, nullable=True, index=True) + password_hash = db.Column(db.String(255), nullable=True) + view_count = db.Column(db.Integer, nullable=False, default=0) + last_accessed_at = db.Column(db.DateTime, nullable=True) + is_active = db.Column(db.Boolean, nullable=False, default=True, index=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + saved_view = db.relationship("SavedReportView", backref=db.backref("shared_links", lazy="dynamic")) + creator = db.relationship("User", backref=db.backref("shared_report_links", lazy="dynamic")) + + def __init__(self, saved_view_id, created_by_id, report_config_snapshot, **kwargs): + self.token = kwargs.get("token") or str(uuid.uuid4()) + self.saved_view_id = saved_view_id + self.created_by_id = created_by_id + if isinstance(report_config_snapshot, dict): + self.report_config_snapshot = json.dumps(report_config_snapshot) + else: + self.report_config_snapshot = report_config_snapshot + self.report_name = kwargs.get("report_name") + self.expires_at = kwargs.get("expires_at") + self.is_active = kwargs.get("is_active", True) + password = kwargs.get("password") + if password: + self.set_password(password) + + def __repr__(self): + return f"" + + def set_password(self, password): + if password: + self.password_hash = generate_password_hash(password) + else: + self.password_hash = None + + def check_password(self, password): + if not self.password_hash: + return True + if not password: + return False + return check_password_hash(self.password_hash, password) + + @property + def requires_password(self): + return bool(self.password_hash) + + def is_expired(self): + if not self.expires_at: + return False + return datetime.utcnow() >= self.expires_at + + def is_accessible(self): + return self.is_active and not self.is_expired() + + def get_config(self): + try: + return json.loads(self.report_config_snapshot) + except (json.JSONDecodeError, TypeError, ValueError): + return {} + + def record_access(self): + self.view_count = (self.view_count or 0) + 1 + self.last_accessed_at = datetime.utcnow() + + def to_dict(self, include_url=False): + data = { + "token": self.token, + "saved_view_id": self.saved_view_id, + "report_name": self.report_name, + "created_by_id": self.created_by_id, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + "requires_password": self.requires_password, + "view_count": self.view_count, + "last_accessed_at": self.last_accessed_at.isoformat() if self.last_accessed_at else None, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + if include_url: + from flask import url_for + + data["url"] = url_for("custom_reports.view_shared_report", token=self.token, _external=True) + return data diff --git a/app/models/user.py b/app/models/user.py index 057271a4a..af005e4d4 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -77,6 +77,12 @@ class User(UserMixin, db.Model): # Time rounding preferences time_rounding_enabled = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable time rounding time_rounding_minutes = db.Column(db.Integer, default=1, nullable=False) # Rounding interval: 1, 5, 10, 15, 30, 60 + + # Pomodoro / focus session defaults + pomodoro_length = db.Column(db.Integer, default=25, nullable=False) + pomodoro_short_break = db.Column(db.Integer, default=5, nullable=False) + pomodoro_long_break = db.Column(db.Integer, default=15, nullable=False) + pomodoro_long_break_interval = db.Column(db.Integer, default=4, nullable=False) time_rounding_method = db.Column( db.String(10), default="nearest", nullable=False ) # 'nearest', 'up', 'down', or 'boundary' diff --git a/app/models/workday_session.py b/app/models/workday_session.py index 8df23c0a9..b851d3a4f 100644 --- a/app/models/workday_session.py +++ b/app/models/workday_session.py @@ -18,10 +18,16 @@ class WorkdaySession(db.Model): auto_closed = db.Column(db.Boolean, default=False, nullable=False) auto_close_confirmed_at = db.Column(db.DateTime, nullable=True) source = db.Column(db.String(20), default="manual", nullable=False) # manual, kiosk, mobile + latitude = db.Column(db.Float, nullable=True) + longitude = db.Column(db.Float, nullable=True) + accuracy_m = db.Column(db.Float, nullable=True) + geofence_id = db.Column(db.Integer, db.ForeignKey("geofences.id"), nullable=True) + geofence_status = db.Column(db.String(20), nullable=True) # inside, outside, unknown, no_geofences created_at = db.Column(db.DateTime, default=local_now, nullable=False) updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) user = db.relationship("User", backref=db.backref("workday_sessions", lazy="dynamic")) + geofence = db.relationship("Geofence", backref=db.backref("workday_sessions", lazy="dynamic")) def __init__( self, @@ -95,6 +101,11 @@ def to_dict(self): ), "source": self.source, "is_active": self.is_active, + "latitude": self.latitude, + "longitude": self.longitude, + "accuracy_m": self.accuracy_m, + "geofence_id": self.geofence_id, + "geofence_status": self.geofence_status, } @classmethod diff --git a/migrations/versions/185_add_pomodoro_user_defaults.py b/migrations/versions/185_add_pomodoro_user_defaults.py new file mode 100644 index 000000000..07d86bfb2 --- /dev/null +++ b/migrations/versions/185_add_pomodoro_user_defaults.py @@ -0,0 +1,51 @@ +"""Add Pomodoro default length columns to users. + +Revision ID: 185_add_pomodoro_user_defaults +Revises: 184_merge_183_heads +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "185_add_pomodoro_user_defaults" +down_revision = "184_merge_183_heads" +branch_labels = None +depends_on = None + + +def _has_column(inspector, table_name: str, column_name: str) -> bool: + try: + return column_name in {c["name"] for c in inspector.get_columns(table_name)} + except Exception: + return False + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + cols = [ + ("pomodoro_length", sa.Integer(), "25"), + ("pomodoro_short_break", sa.Integer(), "5"), + ("pomodoro_long_break", sa.Integer(), "15"), + ("pomodoro_long_break_interval", sa.Integer(), "4"), + ] + for name, col_type, default in cols: + if not _has_column(inspector, "users", name): + op.add_column( + "users", + sa.Column(name, col_type, nullable=False, server_default=default), + ) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + for name in ( + "pomodoro_long_break_interval", + "pomodoro_long_break", + "pomodoro_short_break", + "pomodoro_length", + ): + if _has_column(inspector, "users", name): + op.drop_column("users", name) diff --git a/migrations/versions/186_add_payroll_export_templates.py b/migrations/versions/186_add_payroll_export_templates.py new file mode 100644 index 000000000..34c81ba05 --- /dev/null +++ b/migrations/versions/186_add_payroll_export_templates.py @@ -0,0 +1,41 @@ +"""Add payroll_export_templates table. + +Revision ID: 186_add_payroll_export_templates +Revises: 185_add_pomodoro_user_defaults +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "186_add_payroll_export_templates" +down_revision = "185_add_pomodoro_user_defaults" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "payroll_export_templates" not in inspector.get_table_names(): + op.create_table( + "payroll_export_templates", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=120), nullable=False, unique=True), + sa.Column("columns", sa.JSON(), nullable=False), + sa.Column("grouping", sa.String(length=20), nullable=False, server_default="week"), + sa.Column("filters", sa.JSON(), nullable=True), + sa.Column("format", sa.String(length=10), nullable=False, server_default="csv"), + sa.Column("delimiter", sa.String(length=5), nullable=False, server_default=","), + sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "payroll_export_templates" in inspector.get_table_names(): + op.drop_table("payroll_export_templates") diff --git a/migrations/versions/187_add_integration_sync_errors.py b/migrations/versions/187_add_integration_sync_errors.py new file mode 100644 index 000000000..7599b4c78 --- /dev/null +++ b/migrations/versions/187_add_integration_sync_errors.py @@ -0,0 +1,43 @@ +"""Add integration_sync_errors dead-letter table. + +Revision ID: 187_add_integration_sync_errors +Revises: 186_add_payroll_export_templates +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "187_add_integration_sync_errors" +down_revision = "186_add_payroll_export_templates" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "integration_sync_errors" not in inspector.get_table_names(): + op.create_table( + "integration_sync_errors", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("connector", sa.String(length=50), nullable=False), + sa.Column("entity_type", sa.String(length=50), nullable=False), + sa.Column("entity_id", sa.Integer(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=False), + sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("resolved", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("last_retry_at", sa.DateTime(), nullable=True), + ) + op.create_index("ix_integration_sync_errors_connector", "integration_sync_errors", ["connector"]) + op.create_index("ix_integration_sync_errors_entity_type", "integration_sync_errors", ["entity_type"]) + op.create_index("ix_integration_sync_errors_resolved", "integration_sync_errors", ["resolved"]) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "integration_sync_errors" in inspector.get_table_names(): + op.drop_table("integration_sync_errors") diff --git a/migrations/versions/188_add_activitywatch_rules.py b/migrations/versions/188_add_activitywatch_rules.py new file mode 100644 index 000000000..a47bff136 --- /dev/null +++ b/migrations/versions/188_add_activitywatch_rules.py @@ -0,0 +1,71 @@ +"""Add ActivityWatch rules and pending activities tables. + +Revision ID: 188_add_activitywatch_rules +Revises: 187_add_integration_sync_errors +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "188_add_activitywatch_rules" +down_revision = "187_add_integration_sync_errors" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + tables = inspector.get_table_names() + if "activitywatch_rules" not in tables: + op.create_table( + "activitywatch_rules", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("name", sa.String(120), nullable=False), + sa.Column("pattern_type", sa.String(30), nullable=False, server_default="app"), + sa.Column("pattern_value", sa.String(500), nullable=False), + sa.Column("project_id", sa.Integer(), sa.ForeignKey("projects.id"), nullable=True), + sa.Column("task_id", sa.Integer(), sa.ForeignKey("tasks.id"), nullable=True), + sa.Column("tags", sa.JSON(), nullable=True), + sa.Column("billable", sa.Boolean(), nullable=False, server_default=sa.text("1")), + sa.Column("min_duration_seconds", sa.Integer(), nullable=True), + sa.Column("priority", sa.Integer(), nullable=False, server_default="100"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_activitywatch_rules_user_id", "activitywatch_rules", ["user_id"]) + if "pending_activities" not in tables: + op.create_table( + "pending_activities", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("aw_bucket", sa.String(255), nullable=True), + sa.Column("app_name", sa.String(255), nullable=True), + sa.Column("title", sa.String(500), nullable=True), + sa.Column("url", sa.String(1000), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=False), + sa.Column("duration_seconds", sa.Integer(), nullable=False, server_default="0"), + sa.Column("matched_rule_id", sa.Integer(), sa.ForeignKey("activitywatch_rules.id"), nullable=True), + sa.Column("suggested_project_id", sa.Integer(), sa.ForeignKey("projects.id"), nullable=True), + sa.Column("suggested_task_id", sa.Integer(), sa.ForeignKey("tasks.id"), nullable=True), + sa.Column("suggested_billable", sa.Boolean(), nullable=False, server_default=sa.text("1")), + sa.Column("external_uid", sa.String(255), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_pending_activities_user_id", "pending_activities", ["user_id"]) + op.create_index("ix_pending_activities_status", "pending_activities", ["status"]) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + tables = inspector.get_table_names() + if "pending_activities" in tables: + op.drop_table("pending_activities") + if "activitywatch_rules" in tables: + op.drop_table("activitywatch_rules") diff --git a/migrations/versions/189_add_recurring_project_costs.py b/migrations/versions/189_add_recurring_project_costs.py new file mode 100644 index 000000000..1c134abee --- /dev/null +++ b/migrations/versions/189_add_recurring_project_costs.py @@ -0,0 +1,57 @@ +"""Add recurring project costs table. + +Revision ID: 189_add_recurring_project_costs +Revises: 188_add_activitywatch_rules +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "189_add_recurring_project_costs" +down_revision = "188_add_activitywatch_rules" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "recurring_project_costs" in inspector.get_table_names(): + return + + op.create_table( + "recurring_project_costs", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("project_id", sa.Integer(), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("description", sa.String(length=500), nullable=False), + sa.Column("category", sa.String(length=50), nullable=False), + sa.Column("amount", sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column("billable", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("currency_code", sa.String(length=3), nullable=False, server_default="EUR"), + sa.Column("frequency", sa.String(length=20), nullable=False), + sa.Column("interval", sa.Integer(), nullable=False, server_default="1"), + sa.Column("next_run_date", sa.Date(), nullable=False), + sa.Column("end_date", sa.Date(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("last_generated_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + op.create_index("ix_recurring_project_costs_project_id", "recurring_project_costs", ["project_id"]) + op.create_index("ix_recurring_project_costs_user_id", "recurring_project_costs", ["user_id"]) + op.create_index("ix_recurring_project_costs_next_run_date", "recurring_project_costs", ["next_run_date"]) + op.create_index("ix_recurring_project_costs_is_active", "recurring_project_costs", ["is_active"]) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "recurring_project_costs" not in inspector.get_table_names(): + return + op.drop_index("ix_recurring_project_costs_is_active", table_name="recurring_project_costs") + op.drop_index("ix_recurring_project_costs_next_run_date", table_name="recurring_project_costs") + op.drop_index("ix_recurring_project_costs_user_id", table_name="recurring_project_costs") + op.drop_index("ix_recurring_project_costs_project_id", table_name="recurring_project_costs") + op.drop_table("recurring_project_costs") diff --git a/migrations/versions/190_add_shared_report_links.py b/migrations/versions/190_add_shared_report_links.py new file mode 100644 index 000000000..7d18d86af --- /dev/null +++ b/migrations/versions/190_add_shared_report_links.py @@ -0,0 +1,56 @@ +"""Add shared report links table. + +Revision ID: 190_add_shared_report_links +Revises: 189_add_recurring_project_costs +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "190_add_shared_report_links" +down_revision = "189_add_recurring_project_costs" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "shared_report_links" in inspector.get_table_names(): + return + + op.create_table( + "shared_report_links", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("token", sa.String(length=36), nullable=False), + sa.Column("saved_view_id", sa.Integer(), sa.ForeignKey("saved_report_views.id", ondelete="CASCADE"), nullable=False), + sa.Column("report_name", sa.String(length=120), nullable=True), + sa.Column("report_config_snapshot", sa.Text(), nullable=False), + sa.Column("created_by_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("password_hash", sa.String(length=255), nullable=True), + sa.Column("view_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("last_accessed_at", sa.DateTime(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + op.create_index("ix_shared_report_links_token", "shared_report_links", ["token"], unique=True) + op.create_index("ix_shared_report_links_saved_view_id", "shared_report_links", ["saved_view_id"]) + op.create_index("ix_shared_report_links_created_by_id", "shared_report_links", ["created_by_id"]) + op.create_index("ix_shared_report_links_expires_at", "shared_report_links", ["expires_at"]) + op.create_index("ix_shared_report_links_is_active", "shared_report_links", ["is_active"]) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "shared_report_links" not in inspector.get_table_names(): + return + op.drop_index("ix_shared_report_links_is_active", table_name="shared_report_links") + op.drop_index("ix_shared_report_links_expires_at", table_name="shared_report_links") + op.drop_index("ix_shared_report_links_created_by_id", table_name="shared_report_links") + op.drop_index("ix_shared_report_links_saved_view_id", table_name="shared_report_links") + op.drop_index("ix_shared_report_links_token", table_name="shared_report_links") + op.drop_table("shared_report_links") diff --git a/migrations/versions/191_add_geofences.py b/migrations/versions/191_add_geofences.py new file mode 100644 index 000000000..5c43c3f1b --- /dev/null +++ b/migrations/versions/191_add_geofences.py @@ -0,0 +1,78 @@ +"""Add geofences and workday session location fields. + +Revision ID: 191_add_geofences +Revises: 190_add_shared_report_links +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "191_add_geofences" +down_revision = "190_add_shared_report_links" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + tables = inspector.get_table_names() + + if "geofences" not in tables: + op.create_table( + "geofences", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(120), nullable=False), + sa.Column("lat", sa.Float(), nullable=False), + sa.Column("lng", sa.Float(), nullable=False), + sa.Column("radius_m", sa.Float(), nullable=False, server_default="100"), + sa.Column("address", sa.String(500), nullable=True), + sa.Column("project_id", sa.Integer(), sa.ForeignKey("projects.id"), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("policy", sa.String(10), nullable=False, server_default="log"), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_geofences_project_id", "geofences", ["project_id"]) + + ws_cols = {c["name"] for c in inspector.get_columns("workday_sessions")} + if "latitude" not in ws_cols: + op.add_column("workday_sessions", sa.Column("latitude", sa.Float(), nullable=True)) + if "longitude" not in ws_cols: + op.add_column("workday_sessions", sa.Column("longitude", sa.Float(), nullable=True)) + if "accuracy_m" not in ws_cols: + op.add_column("workday_sessions", sa.Column("accuracy_m", sa.Float(), nullable=True)) + if "geofence_id" not in ws_cols: + op.add_column("workday_sessions", sa.Column("geofence_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + "fk_workday_sessions_geofence_id", + "workday_sessions", + "geofences", + ["geofence_id"], + ["id"], + ) + if "geofence_status" not in ws_cols: + op.add_column("workday_sessions", sa.Column("geofence_status", sa.String(20), nullable=True)) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + ws_cols = {c["name"] for c in inspector.get_columns("workday_sessions")} + + if "geofence_status" in ws_cols: + op.drop_column("workday_sessions", "geofence_status") + if "geofence_id" in ws_cols: + op.drop_constraint("fk_workday_sessions_geofence_id", "workday_sessions", type_="foreignkey") + op.drop_column("workday_sessions", "geofence_id") + if "accuracy_m" in ws_cols: + op.drop_column("workday_sessions", "accuracy_m") + if "longitude" in ws_cols: + op.drop_column("workday_sessions", "longitude") + if "latitude" in ws_cols: + op.drop_column("workday_sessions", "latitude") + + if "geofences" in inspector.get_table_names(): + op.drop_index("ix_geofences_project_id", table_name="geofences") + op.drop_table("geofences") From bc9bdbd68ed25003661f625d2c2526b1e12d1ee0 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:49:35 +0200 Subject: [PATCH 02/21] feat(focus): wire Pomodoro sessions into APIs and timer UI Delegate focus routes to PomodoroService, add active/cycle/interrupt endpoints, timer panel, user length defaults, and export field fixes. --- app/routes/api.py | 116 +++++++++----- app/routes/user.py | 13 ++ app/templates/timer/timer_page.html | 211 ++++++++++++++++++++++++++ app/templates/user/settings.html | 42 ++++- app/utils/data_export.py | 16 +- tests/test_pomodoro_focus_sessions.py | 51 +++++++ 6 files changed, 404 insertions(+), 45 deletions(-) create mode 100644 tests/test_pomodoro_focus_sessions.py diff --git a/app/routes/api.py b/app/routes/api.py index 21f21bc78..79e04c31b 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1043,39 +1043,37 @@ def project_forecast(project_id): @api_bp.route("/api/focus-sessions/start", methods=["POST"]) @login_required def start_focus_session(): - data = request.get_json() or {} - project_id = data.get("project_id") - task_id = data.get("task_id") - pomodoro_length = int(data.get("pomodoro_length") or 25) - short_break_length = int(data.get("short_break_length") or 5) - long_break_length = int(data.get("long_break_length") or 15) - long_break_interval = int(data.get("long_break_interval") or 4) - link_active_timer = bool(data.get("link_active_timer", True)) + from app.services.pomodoro_service import PomodoroService - time_entry_id = None - if link_active_timer and current_user.active_timer: - time_entry_id = current_user.active_timer.id - - fs = FocusSession( + data = request.get_json() or {} + service = PomodoroService() + result = service.start_session( user_id=current_user.id, - project_id=project_id, - task_id=task_id, - time_entry_id=time_entry_id, - pomodoro_length=pomodoro_length, - short_break_length=short_break_length, - long_break_length=long_break_length, - long_break_interval=long_break_interval, + project_id=data.get("project_id"), + task_id=data.get("task_id"), + pomodoro_length=int( + data.get("pomodoro_length") or getattr(current_user, "pomodoro_length", None) or 25 + ), + short_break_length=int( + data.get("short_break_length") or getattr(current_user, "pomodoro_short_break", None) or 5 + ), + long_break_length=int( + data.get("long_break_length") or getattr(current_user, "pomodoro_long_break", None) or 15 + ), + long_break_interval=int( + data.get("long_break_interval") or getattr(current_user, "pomodoro_long_break_interval", None) or 4 + ), ) - db.session.add(fs) - if not safe_commit("start_focus_session", {"user_id": current_user.id}): - return jsonify({"error": "Database error while starting focus session"}), 500 - - return jsonify({"success": True, "session": fs.to_dict()}) + if not result.get("success"): + return jsonify(result), 409 + return jsonify(result) @api_bp.route("/api/focus-sessions/finish", methods=["POST"]) @login_required def finish_focus_session(): + from app.services.pomodoro_service import PomodoroService + data = request.get_json() or {} session_id = data.get("session_id") if not session_id: @@ -1084,28 +1082,64 @@ def finish_focus_session(): if fs.user_id != current_user.id and not current_user.is_admin: return jsonify({"error": "Access denied"}), 403 - fs.ended_at = datetime.utcnow() - fs.cycles_completed = int(data.get("cycles_completed") or 0) - fs.interruptions = int(data.get("interruptions") or 0) - notes = (data.get("notes") or "").strip() - fs.notes = notes or fs.notes - if not safe_commit("finish_focus_session", {"session_id": fs.id}): - return jsonify({"error": "Database error while finishing focus session"}), 500 - return jsonify({"success": True, "session": fs.to_dict()}) + result = PomodoroService().end_session(session_id=session_id, notes=(data.get("notes") or "").strip() or None) + return jsonify(result) + + +@api_bp.route("/api/focus-sessions/active", methods=["GET"]) +@login_required +def active_focus_session(): + from app.services.pomodoro_service import PomodoroService + + session = PomodoroService().get_active_session(current_user.id) + return jsonify({"session": session.to_dict() if session else None}) + + +@api_bp.route("/api/focus-sessions//cycle", methods=["POST"]) +@login_required +def complete_focus_cycle(session_id): + from app.services.pomodoro_service import PomodoroService + + fs = FocusSession.query.get_or_404(session_id) + if fs.user_id != current_user.id and not current_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + if fs.ended_at: + return jsonify({"error": "Session already ended"}), 400 + return jsonify(PomodoroService().complete_cycle(session_id)) + + +@api_bp.route("/api/focus-sessions//interrupt", methods=["POST"]) +@login_required +def interrupt_focus_session(session_id): + from app.services.pomodoro_service import PomodoroService + + data = request.get_json() or {} + fs = FocusSession.query.get_or_404(session_id) + if fs.user_id != current_user.id and not current_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + if fs.ended_at: + return jsonify({"error": "Session already ended"}), 400 + return jsonify(PomodoroService().log_interruption(session_id, reason=data.get("reason"))) @api_bp.route("/api/focus-sessions/summary") @login_required def focus_sessions_summary(): - """Return simple summary counts for recent focus sessions for the current user.""" + """Return Pomodoro session statistics for the current user.""" + from app.services.pomodoro_service import PomodoroService + days = int(request.args.get("days", 7)) - since = datetime.utcnow() - timedelta(days=days) - q = FocusSession.query.filter(FocusSession.user_id == current_user.id, FocusSession.started_at >= since) - sessions = q.order_by(FocusSession.started_at.desc()).all() - total = len(sessions) - cycles = sum(s.cycles_completed or 0 for s in sessions) - interrupts = sum(s.interruptions or 0 for s in sessions) - return jsonify({"total_sessions": total, "cycles_completed": cycles, "interruptions": interrupts}) + stats = PomodoroService().get_session_stats(current_user.id, days=days) + return jsonify( + { + "total_sessions": stats["total_sessions"], + "cycles_completed": stats["total_cycles"], + "interruptions": stats["total_interruptions"], + "total_minutes": stats["total_minutes"], + "average_cycles_per_session": stats["average_cycles_per_session"], + "average_minutes_per_session": stats["average_minutes_per_session"], + } + ) @api_bp.route("/api/recurring-blocks", methods=["GET", "POST"]) diff --git a/app/routes/user.py b/app/routes/user.py index e12d2b088..e00580feb 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -191,6 +191,19 @@ def settings(): if time_rounding_minimum is not None and time_rounding_minimum in [0, 5, 10, 15, 30, 60]: current_user.time_rounding_minimum_minutes = time_rounding_minimum + # Pomodoro / focus defaults + for form_key, attr, lo, hi, default in ( + ("pomodoro_length", "pomodoro_length", 5, 120, 25), + ("pomodoro_short_break", "pomodoro_short_break", 1, 60, 5), + ("pomodoro_long_break", "pomodoro_long_break", 5, 60, 15), + ("pomodoro_long_break_interval", "pomodoro_long_break_interval", 2, 10, 4), + ): + raw = request.form.get(form_key, type=int) + if raw is not None and lo <= raw <= hi: + setattr(current_user, attr, raw) + elif raw is None and hasattr(current_user, attr): + pass + # Overtime settings standard_hours_per_day = request.form.get("standard_hours_per_day", type=float) if standard_hours_per_day is not None: diff --git a/app/templates/timer/timer_page.html b/app/templates/timer/timer_page.html index da27c2572..6d8767cba 100644 --- a/app/templates/timer/timer_page.html +++ b/app/templates/timer/timer_page.html @@ -264,6 +264,49 @@

{{ _('Start New Timer') }}

+ +
+

{{ _('Focus / Pomodoro') }}

+

{{ _('Work in focused cycles with short breaks') }}

+
+
+ + + + +
+
25:00
+
{{ _('Ready') }}
+
+
+
+ 0 {{ _('cycles') }} · + 0 {{ _('interrupts') }} +
+
+ + + + +
+
+
+ {% if recent_projects %}
@@ -586,4 +629,172 @@

{{ _("Today's Stats") }}

} }; + {% endblock %} diff --git a/app/templates/user/settings.html b/app/templates/user/settings.html index 678fad291..f52ae375b 100644 --- a/app/templates/user/settings.html +++ b/app/templates/user/settings.html @@ -301,11 +301,51 @@

+ +
+ +

+ {{ _('Focus / Pomodoro Defaults') }} +

+ +
+
+

+ {{ _('Default lengths used when starting a focus session from the Timer page.') }} +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ {# Custom theme picker. Self-contained: persists via /api/user/theme rather than the surrounding form, so the rest of this form keeps working unchanged. #} {% include 'components/theme_picker.html' %} -
diff --git a/app/utils/data_export.py b/app/utils/data_export.py index 88990e38c..c85592032 100644 --- a/app/utils/data_export.py +++ b/app/utils/data_export.py @@ -529,12 +529,22 @@ def _comment_to_dict(comment): def _focus_session_to_dict(session): """Convert focus session to dictionary""" + duration_minutes = None + if session.started_at and session.ended_at: + duration_minutes = int((session.ended_at - session.started_at).total_seconds() / 60) return { "id": session.id, "user_id": session.user_id, - "start_time": session.start_time.isoformat() if session.start_time else None, - "end_time": session.end_time.isoformat() if session.end_time else None, - "duration_minutes": session.duration_minutes, + "project_id": session.project_id, + "task_id": session.task_id, + "time_entry_id": session.time_entry_id, + "started_at": session.started_at.isoformat() if session.started_at else None, + "ended_at": session.ended_at.isoformat() if session.ended_at else None, + "duration_minutes": duration_minutes, + "pomodoro_length": session.pomodoro_length, + "cycles_completed": session.cycles_completed, + "interruptions": session.interruptions, + "notes": session.notes, "created_at": session.created_at.isoformat() if session.created_at else None, } diff --git a/tests/test_pomodoro_focus_sessions.py b/tests/test_pomodoro_focus_sessions.py new file mode 100644 index 000000000..967a8f0ca --- /dev/null +++ b/tests/test_pomodoro_focus_sessions.py @@ -0,0 +1,51 @@ +"""Tests for PomodoroService and focus-session export wiring.""" + +from app.models.focus_session import FocusSession +from app.services.pomodoro_service import PomodoroService +from app.utils.data_export import _focus_session_to_dict + + +def test_pomodoro_start_cycle_interrupt_end(app, test_user): + with app.app_context(): + svc = PomodoroService() + started = svc.start_session(user_id=test_user.id, pomodoro_length=25) + assert started["success"] is True + session_id = started["session"]["id"] + + active = svc.get_active_session(test_user.id) + assert active is not None + assert active.id == session_id + + # Second start should fail while active + again = svc.start_session(user_id=test_user.id) + assert again["success"] is False + + cycle = svc.complete_cycle(session_id) + assert cycle["success"] is True + assert cycle["session"]["cycles_completed"] == 1 + + interrupt = svc.log_interruption(session_id, reason="phone") + assert interrupt["session"]["interruptions"] == 1 + + ended = svc.end_session(session_id, notes="done") + assert ended["success"] is True + assert ended["session"]["ended_at"] is not None + + assert svc.get_active_session(test_user.id) is None + stats = svc.get_session_stats(test_user.id, days=7) + assert stats["total_sessions"] >= 1 + assert stats["total_cycles"] >= 1 + + +def test_focus_session_export_dict_fields(app, test_user): + with app.app_context(): + svc = PomodoroService() + started = svc.start_session(user_id=test_user.id) + fs_id = started["session"]["id"] + svc.end_session(fs_id) + fs = FocusSession.query.get(fs_id) + data = _focus_session_to_dict(fs) + assert "started_at" in data + assert "ended_at" in data + assert "start_time" not in data + assert data["duration_minutes"] is not None From 38e5f737d4acfb1c3b1abfdcf5a52b8ce6060d71 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:49:43 +0200 Subject: [PATCH 03/21] feat(finance): add expense lifecycle parity on mobile and desktop Expose approve/reject APIs and ship expense detail, richer create flows, and a dedicated reports screen across Flutter and Electron clients. --- app/routes/api_v1_expenses.py | 58 ++++ .../src/renderer-react/src/services/api.js | 5 + .../renderer-react/src/views/ExpensesView.jsx | 243 +++++++++++++++-- .../screens/expense_detail_screen.dart | 258 ++++++++++++++++++ .../screens/finance_workforce_screen.dart | 80 +++++- .../presentation/screens/more_hub_screen.dart | 2 + .../presentation/screens/report_screen.dart | 166 +++++++++++ 7 files changed, 780 insertions(+), 32 deletions(-) create mode 100644 mobile/lib/presentation/screens/expense_detail_screen.dart create mode 100644 mobile/lib/presentation/screens/report_screen.dart diff --git a/app/routes/api_v1_expenses.py b/app/routes/api_v1_expenses.py index 9036b0b68..2f2351833 100644 --- a/app/routes/api_v1_expenses.py +++ b/app/routes/api_v1_expenses.py @@ -177,3 +177,61 @@ def delete_expense(expense_id): if not result.get("success"): return error_response(result.get("message", "Could not reject expense"), status_code=400) return jsonify({"message": "Expense rejected successfully"}) + + +@api_v1_expenses_bp.route("/expenses//approve", methods=["POST"]) +@require_api_token("write:expenses") +def api_approve_expense(expense_id): + """Approve a pending expense (admin / approver).""" + from app import db + from app.models import Expense + from app.utils.db import safe_commit + + if not g.api_user.is_admin and not getattr(g.api_user, "has_permission", lambda *_: False)("approve_expenses"): + # Fall back: admins only when permission helper unavailable + if not g.api_user.is_admin: + return forbidden_response("Only administrators can approve expenses") + + expense = Expense.query.get_or_404(expense_id) + if expense.status != "pending": + return error_response("Only pending expenses can be approved", status_code=400) + + data = request.get_json() or {} + notes = (data.get("notes") or data.get("approval_notes") or "").strip() or None + expense.approve(g.api_user.id, notes) + if not safe_commit("api_approve_expense", {"expense_id": expense_id}): + return error_response("Could not approve expense", status_code=500) + + try: + from app.utils.integration_sync_hooks import trigger_expense_sync + + trigger_expense_sync(expense) + except Exception: + pass + + return jsonify({"message": "Expense approved successfully", "expense": expense.to_dict()}) + + +@api_v1_expenses_bp.route("/expenses//reject", methods=["POST"]) +@require_api_token("write:expenses") +def api_reject_expense(expense_id): + """Reject a pending expense.""" + from app.models import Expense + from app.utils.db import safe_commit + + if not g.api_user.is_admin: + return forbidden_response("Only administrators can reject expenses") + + expense = Expense.query.get_or_404(expense_id) + if expense.status != "pending": + return error_response("Only pending expenses can be rejected", status_code=400) + + data = request.get_json() or {} + reason = (data.get("reason") or "").strip() + if not reason: + return validation_error_response(errors={"reason": ["reason is required"]}, message="reason is required") + + expense.reject(g.api_user.id, reason) + if not safe_commit("api_reject_expense", {"expense_id": expense_id}): + return error_response("Could not reject expense", status_code=500) + return jsonify({"message": "Expense rejected successfully", "expense": expense.to_dict()}) diff --git a/desktop/src/renderer-react/src/services/api.js b/desktop/src/renderer-react/src/services/api.js index c3a455e2f..e5e00a939 100644 --- a/desktop/src/renderer-react/src/services/api.js +++ b/desktop/src/renderer-react/src/services/api.js @@ -203,6 +203,11 @@ export class ApiClient { } getExpenses(params = {}) { return this.unwrap(this.client.get('/api/v1/expenses', { params })); } createExpense(data) { return this.unwrap(this.client.post('/api/v1/expenses', data)); } + getExpense(id) { return this.unwrap(this.client.get(`/api/v1/expenses/${id}`)); } + updateExpense(id, data) { return this.unwrap(this.client.patch(`/api/v1/expenses/${id}`, data)); } + deleteExpense(id) { return this.unwrap(this.client.delete(`/api/v1/expenses/${id}`)); } + approveExpense(id, notes) { return this.unwrap(this.client.post(`/api/v1/expenses/${id}/approve`, { notes })); } + rejectExpense(id, reason) { return this.unwrap(this.client.post(`/api/v1/expenses/${id}/reject`, { reason })); } getClients(params = {}) { return this.unwrap(this.client.get('/api/v1/clients', { params })); } createClient(data) { return this.unwrap(this.client.post('/api/v1/clients', data)); } createProject(data) { return this.unwrap(this.client.post('/api/v1/projects', data)); } diff --git a/desktop/src/renderer-react/src/views/ExpensesView.jsx b/desktop/src/renderer-react/src/views/ExpensesView.jsx index 5420316b8..90002cb9c 100644 --- a/desktop/src/renderer-react/src/views/ExpensesView.jsx +++ b/desktop/src/renderer-react/src/views/ExpensesView.jsx @@ -1,25 +1,74 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { classifyAxiosError } from '../services/api.js'; import { EmptyState, SkeletonList, ViewHeader } from '../components/ui.jsx'; export function ExpensesView({ expenses, projects, loading, apiClient, onRefresh, showToast }) { const [busy, setBusy] = useState(false); - const createExpense = async () => { - const project = projects[0]; - if (!project) { - showToast('Need a project to log an expense', 'error'); + const [selectedId, setSelectedId] = useState(null); + const [draft, setDraft] = useState(null); + + const selected = useMemo( + () => (expenses || []).find((e) => e.id === selectedId) || null, + [expenses, selectedId], + ); + + const openCreate = () => { + setSelectedId(null); + setDraft({ + title: '', + category: 'general', + amount: '', + expense_date: new Date().toISOString().slice(0, 10), + project_id: projects?.[0]?.id || '', + billable: false, + reimbursable: true, + }); + }; + + const openEdit = (item) => { + setSelectedId(item.id); + setDraft({ + title: item.title || '', + category: item.category || 'general', + amount: String(item.amount ?? ''), + expense_date: item.expense_date || new Date().toISOString().slice(0, 10), + project_id: item.project_id || '', + billable: !!item.billable, + reimbursable: item.reimbursable !== false, + status: item.status || 'pending', + }); + }; + + const saveExpense = async () => { + if (!draft?.title || !draft?.category || !draft?.amount) { + showToast('Title, category, and amount are required', 'error'); + return; + } + const amount = Number(draft.amount); + if (!Number.isFinite(amount) || amount <= 0) { + showToast('Enter a valid amount', 'error'); return; } setBusy(true); try { - await apiClient.createExpense({ - project_id: project.id, - title: 'Desktop expense', - amount: 0, - expense_date: new Date().toISOString().slice(0, 10), - category: 'general', - }); - showToast('Expense created', 'success'); + const payload = { + title: draft.title.trim(), + category: draft.category.trim(), + amount, + expense_date: draft.expense_date, + billable: !!draft.billable, + reimbursable: !!draft.reimbursable, + ...(draft.project_id ? { project_id: Number(draft.project_id) } : {}), + }; + if (selectedId) { + await apiClient.updateExpense(selectedId, payload); + showToast('Expense updated', 'success'); + } else { + await apiClient.createExpense(payload); + showToast('Expense created', 'success'); + } + setDraft(null); + setSelectedId(null); onRefresh(); } catch (error) { showToast(classifyAxiosError(error).message, 'error'); @@ -27,13 +76,44 @@ export function ExpensesView({ expenses, projects, loading, apiClient, onRefresh setBusy(false); } }; + + const approve = async () => { + if (!selectedId) return; + setBusy(true); + try { + await apiClient.approveExpense(selectedId); + showToast('Expense approved', 'success'); + onRefresh(); + } catch (error) { + showToast(classifyAxiosError(error).message, 'error'); + } finally { + setBusy(false); + } + }; + + const reject = async () => { + if (!selectedId) return; + const reason = window.prompt('Rejection reason'); + if (!reason) return; + setBusy(true); + try { + await apiClient.rejectExpense(selectedId, reason); + showToast('Expense rejected', 'success'); + onRefresh(); + } catch (error) { + showToast(classifyAxiosError(error).message, 'error'); + } finally { + setBusy(false); + } + }; + return (
+ } @@ -41,19 +121,128 @@ export function ExpensesView({ expenses, projects, loading, apiClient, onRefresh {loading ? ( ) : ( -
- {expenses?.length ? ( - expenses.map((item) => ( -
- {item.title || item.category || `Expense ${item.id}`} - - {item.amount} · {item.expense_date || item.status} - +
+
+ {expenses?.length ? ( + expenses.map((item) => ( + + )) + ) : ( + + )} +
+ +
+ {!draft ? ( + + ) : ( +
+

{selectedId ? 'Edit expense' : 'New expense'}

+ + + + + + + + {selected &&
Status: {selected.status || 'pending'}
} +
+ + {selectedId && selected?.status === 'pending' && ( + <> + + + + )} + +
- )) - ) : ( - - )} + )} +
)}
diff --git a/mobile/lib/presentation/screens/expense_detail_screen.dart b/mobile/lib/presentation/screens/expense_detail_screen.dart new file mode 100644 index 000000000..86472bc23 --- /dev/null +++ b/mobile/lib/presentation/screens/expense_detail_screen.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/api_provider.dart'; + +class ExpenseDetailScreen extends ConsumerStatefulWidget { + const ExpenseDetailScreen({super.key, required this.expenseId}); + + final int expenseId; + + @override + ConsumerState createState() => _ExpenseDetailScreenState(); +} + +class _ExpenseDetailScreenState extends ConsumerState { + late Future> _future; + bool _saving = false; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future> _load() async { + final client = await ref.read(apiClientProvider.future); + if (client == null) throw StateError('Not authenticated'); + final res = await client.getExpense(widget.expenseId); + final expense = res['expense']; + if (expense is Map) return expense; + if (expense is Map) return Map.from(expense); + throw StateError('Expense not found'); + } + + Future _refresh() async { + setState(() => _future = _load()); + await _future; + } + + Future _edit(Map expense) async { + final titleCtrl = TextEditingController(text: (expense['title'] ?? '').toString()); + final categoryCtrl = TextEditingController(text: (expense['category'] ?? '').toString()); + final amountCtrl = TextEditingController(text: (expense['amount'] ?? '').toString()); + var billable = expense['billable'] == true; + var reimbursable = expense['reimbursable'] != false; + + final saved = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('Edit Expense'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: titleCtrl, decoration: const InputDecoration(labelText: 'Title')), + TextField(controller: categoryCtrl, decoration: const InputDecoration(labelText: 'Category')), + TextField( + controller: amountCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Amount'), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Billable'), + value: billable, + onChanged: (v) => setDialogState(() => billable = v), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Reimbursable'), + value: reimbursable, + onChanged: (v) => setDialogState(() => reimbursable = v), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Save')), + ], + ), + ), + ); + if (saved != true) return; + + setState(() => _saving = true); + try { + final client = await ref.read(apiClientProvider.future); + if (client == null) return; + final amount = double.tryParse(amountCtrl.text.trim()); + await client.updateExpense(widget.expenseId, { + 'title': titleCtrl.text.trim(), + 'category': categoryCtrl.text.trim(), + if (amount != null) 'amount': amount, + 'billable': billable, + 'reimbursable': reimbursable, + }); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Expense updated'))); + await _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Update failed: $e'))); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _approve() async { + setState(() => _saving = true); + try { + final client = await ref.read(apiClientProvider.future); + if (client == null) return; + await client.approveExpense(widget.expenseId); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Expense approved'))); + await _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Approve failed: $e'))); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _reject() async { + final reasonCtrl = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Reject Expense'), + content: TextField(controller: reasonCtrl, decoration: const InputDecoration(labelText: 'Reason'), autofocus: true), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Reject')), + ], + ), + ); + if (ok != true || reasonCtrl.text.trim().isEmpty) return; + setState(() => _saving = true); + try { + final client = await ref.read(apiClientProvider.future); + if (client == null) return; + await client.rejectExpense(widget.expenseId, reason: reasonCtrl.text.trim()); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Expense rejected'))); + await _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Reject failed: $e'))); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _delete() async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Expense'), + content: const Text('Delete this expense?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Delete')), + ], + ), + ); + if (ok != true) return; + setState(() => _saving = true); + try { + final client = await ref.read(apiClientProvider.future); + if (client == null) return; + await client.deleteExpense(widget.expenseId); + if (!mounted) return; + Navigator.pop(context, true); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Delete failed: $e'))); + setState(() => _saving = false); + } + } + + Future _markReimbursed(Map expense) async { + setState(() => _saving = true); + try { + final client = await ref.read(apiClientProvider.future); + if (client == null) return; + await client.updateExpense(widget.expenseId, {'reimbursed': true, 'status': 'reimbursed'}); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Marked reimbursed'))); + await _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Update failed: $e'))); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Expense'), + actions: [ + if (_saving) + const Padding( + padding: EdgeInsets.all(16), + child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), + ), + ], + ), + body: FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center(child: Text('Error: ${snap.error}')); + } + final expense = snap.data!; + final status = (expense['status'] ?? 'pending').toString(); + final title = (expense['title'] ?? 'Expense').toString(); + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(title, style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + Text('${expense['amount'] ?? '-'} · ${expense['category'] ?? ''}'), + Text('Date: ${expense['expense_date'] ?? '-'}'), + Text('Status: $status'), + Text('Billable: ${expense['billable'] == true ? 'Yes' : 'No'}'), + Text('Reimbursable: ${expense['reimbursable'] == true ? 'Yes' : 'No'}'), + if (expense['project_name'] != null) Text('Project: ${expense['project_name']}'), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton(onPressed: _saving ? null : () => _edit(expense), child: const Text('Edit')), + if (status == 'pending') ...[ + OutlinedButton(onPressed: _saving ? null : _approve, child: const Text('Approve')), + OutlinedButton(onPressed: _saving ? null : _reject, child: const Text('Reject')), + ], + if (status == 'approved' && expense['reimbursed'] != true) + OutlinedButton(onPressed: _saving ? null : () => _markReimbursed(expense), child: const Text('Mark reimbursed')), + TextButton(onPressed: _saving ? null : _delete, child: const Text('Delete')), + ], + ), + ], + ); + }, + ), + ); + } +} diff --git a/mobile/lib/presentation/screens/finance_workforce_screen.dart b/mobile/lib/presentation/screens/finance_workforce_screen.dart index 96931f6e7..51bb97e02 100644 --- a/mobile/lib/presentation/screens/finance_workforce_screen.dart +++ b/mobile/lib/presentation/screens/finance_workforce_screen.dart @@ -8,7 +8,9 @@ import 'package:share_plus/share_plus.dart'; import '../providers/api_provider.dart'; import '../providers/finance_workforce_providers.dart'; +import 'expense_detail_screen.dart'; import 'invoice_detail_screen.dart'; +import 'report_screen.dart'; class FinanceWorkforceScreen extends ConsumerStatefulWidget { const FinanceWorkforceScreen({super.key}); @@ -304,6 +306,9 @@ class _FinanceWorkforceScreenState extends ConsumerState required String category, required String amount, required String expenseDate, + int? projectId, + bool billable = false, + bool reimbursable = true, }) async { final parsedAmount = double.tryParse(amount); if (parsedAmount == null || parsedAmount <= 0) { @@ -322,6 +327,9 @@ class _FinanceWorkforceScreenState extends ConsumerState 'category': category.trim(), 'amount': parsedAmount, 'expense_date': expenseDate, + if (projectId != null) 'project_id': projectId, + 'billable': billable, + 'reimbursable': reimbursable, }); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -330,9 +338,7 @@ class _FinanceWorkforceScreenState extends ConsumerState await _refresh(); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to create expense: $e')), - ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Create failed: $e'))); } } @@ -341,8 +347,20 @@ class _FinanceWorkforceScreenState extends ConsumerState final categoryController = TextEditingController(text: 'travel'); final amountController = TextEditingController(); DateTime selectedDate = DateTime.now(); + int? selectedProjectId; + var billable = false; + var reimbursable = true; + List> projects = const []; try { + final client = await ref.read(apiClientProvider.future); + if (client != null) { + final projectsRes = await client.getProjects(status: 'active', perPage: 100); + projects = List>.from( + (projectsRes['projects'] ?? projectsRes['items'] ?? const []) as List, + ); + } + if (!mounted) return; await showDialog( context: context, builder: (dialogContext) { @@ -369,6 +387,34 @@ class _FinanceWorkforceScreenState extends ConsumerState keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration(labelText: 'Amount *'), ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: selectedProjectId, + decoration: const InputDecoration(labelText: 'Project'), + items: [ + const DropdownMenuItem(value: null, child: Text('None')), + ...projects.map((p) { + final id = (p['id'] as num?)?.toInt(); + return DropdownMenuItem( + value: id, + child: Text((p['name'] ?? 'Project $id').toString()), + ); + }), + ], + onChanged: (v) => setDialogState(() => selectedProjectId = v), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Billable'), + value: billable, + onChanged: (v) => setDialogState(() => billable = v), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Reimbursable'), + value: reimbursable, + onChanged: (v) => setDialogState(() => reimbursable = v), + ), const SizedBox(height: 12), Row( children: [ @@ -416,6 +462,9 @@ class _FinanceWorkforceScreenState extends ConsumerState category: categoryController.text, amount: amountController.text, expenseDate: selectedDate.toIso8601String().split('T')[0], + projectId: selectedProjectId, + billable: billable, + reimbursable: reimbursable, ); }, child: const Text('Create'), @@ -851,6 +900,13 @@ class _FinanceWorkforceScreenState extends ConsumerState appBar: AppBar( title: const Text('Finance & Workforce'), actions: [ + IconButton( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const ReportScreen())); + }, + icon: const Icon(Icons.bar_chart_outlined), + tooltip: 'Reports', + ), IconButton( onPressed: _openCreateExpenseDialog, icon: const Icon(Icons.add_card_outlined), @@ -1123,15 +1179,29 @@ class _FinanceWorkforceScreenState extends ConsumerState else Column( children: filteredExpenses.take(_expenseVisible).map((expense) { + final expenseId = (expense['id'] as num?)?.toInt(); final category = (expense['category'] ?? 'General').toString(); + final title = (expense['title'] ?? category).toString(); final amount = (expense['amount'] ?? '-').toString(); final date = (expense['expense_date'] ?? expense['date'] ?? '').toString(); + final status = (expense['status'] ?? '').toString(); return ListTile( dense: true, contentPadding: EdgeInsets.zero, - title: Text(category), - subtitle: Text(date), + title: Text(title), + subtitle: Text('$category · $date · $status'), trailing: Text(amount), + onTap: expenseId == null + ? null + : () async { + final changed = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ExpenseDetailScreen(expenseId: expenseId), + ), + ); + if (changed == true && mounted) await _refresh(); + }, ); }).toList(), ), diff --git a/mobile/lib/presentation/screens/more_hub_screen.dart b/mobile/lib/presentation/screens/more_hub_screen.dart index 21c076fb4..aba97db5d 100644 --- a/mobile/lib/presentation/screens/more_hub_screen.dart +++ b/mobile/lib/presentation/screens/more_hub_screen.dart @@ -11,6 +11,7 @@ import 'issues_screen.dart'; import 'kanban_screen.dart'; import 'mileage_screen.dart'; import 'per_diem_screen.dart'; +import 'report_screen.dart'; class MoreHubScreen extends ConsumerStatefulWidget { const MoreHubScreen({super.key}); @@ -73,6 +74,7 @@ class _MoreHubScreenState extends ConsumerState { Widget build(BuildContext context) { final items = <_MoreItem>[ const _MoreItem('Clients', Icons.people_outline, ClientsScreen(), null), + const _MoreItem('Reports', Icons.bar_chart_outlined, ReportScreen(), 'reports'), const _MoreItem('Calendar', Icons.calendar_month_outlined, CalendarScreen(), 'calendar'), const _MoreItem('Kanban', Icons.view_kanban_outlined, KanbanScreen(), 'kanban'), const _MoreItem('CRM', Icons.handshake_outlined, CrmHubScreen(), 'deals'), diff --git a/mobile/lib/presentation/screens/report_screen.dart b/mobile/lib/presentation/screens/report_screen.dart new file mode 100644 index 000000000..f739c5dc6 --- /dev/null +++ b/mobile/lib/presentation/screens/report_screen.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/api_provider.dart'; + +class ReportScreen extends ConsumerStatefulWidget { + const ReportScreen({super.key}); + + @override + ConsumerState createState() => _ReportScreenState(); +} + +class _ReportScreenState extends ConsumerState { + DateTime _start = DateTime.now().subtract(const Duration(days: 30)); + DateTime _end = DateTime.now(); + int? _projectId; + List> _projects = const []; + Map? _summary; + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + _bootstrap(); + } + + Future _bootstrap() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final client = await ref.read(apiClientProvider.future); + if (client == null) throw StateError('Not authenticated'); + final projectsRes = await client.getProjects(); + final projects = List>.from( + (projectsRes['projects'] ?? projectsRes['items'] ?? const []) as List, + ); + setState(() => _projects = projects); + await _loadSummary(); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + String _fmt(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + + Future _loadSummary() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final client = await ref.read(apiClientProvider.future); + if (client == null) throw StateError('Not authenticated'); + final summary = await client.getReportSummary( + startDate: _fmt(_start), + endDate: _fmt(_end), + projectId: _projectId, + ); + setState(() => _summary = summary['summary'] is Map + ? Map.from(summary['summary'] as Map) + : Map.from(summary)); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _pickDate({required bool isStart}) async { + final initial = isStart ? _start : _end; + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked == null) return; + setState(() { + if (isStart) { + _start = picked; + } else { + _end = picked; + } + }); + } + + @override + Widget build(BuildContext context) { + final hours = _summary?['total_hours'] ?? _summary?['hours'] ?? '-'; + final billable = _summary?['billable_hours'] ?? '-'; + final revenue = _summary?['revenue'] ?? _summary?['estimated_revenue'] ?? '-'; + + return Scaffold( + appBar: AppBar(title: const Text('Reports')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => _pickDate(isStart: true), + child: Text('From ${_fmt(_start)}'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: () => _pickDate(isStart: false), + child: Text('To ${_fmt(_end)}'), + ), + ), + ], + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: _projectId, + decoration: const InputDecoration(labelText: 'Project', border: OutlineInputBorder()), + items: [ + const DropdownMenuItem(value: null, child: Text('All projects')), + ..._projects.map((p) { + final id = (p['id'] as num?)?.toInt(); + return DropdownMenuItem( + value: id, + child: Text((p['name'] ?? 'Project $id').toString()), + ); + }), + ], + onChanged: (v) => setState(() => _projectId = v), + ), + const SizedBox(height: 12), + FilledButton(onPressed: _loading ? null : _loadSummary, child: const Text('Run report')), + const SizedBox(height: 16), + if (_loading) const Center(child: CircularProgressIndicator()), + if (_error != null) Text('Error: $_error', style: TextStyle(color: Theme.of(context).colorScheme.error)), + if (!_loading && _summary != null) ...[ + Card( + child: ListTile( + title: const Text('Total hours'), + trailing: Text('$hours'), + ), + ), + Card( + child: ListTile( + title: const Text('Billable hours'), + trailing: Text('$billable'), + ), + ), + Card( + child: ListTile( + title: const Text('Revenue est.'), + trailing: Text('$revenue'), + ), + ), + ], + ], + ), + ); + } +} From 1ba158516f7b96c78eee1133a570943833076e6f Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:49:43 +0200 Subject: [PATCH 04/21] feat(payroll): add configurable payroll export templates Support richer payroll rows, CSV/XLSX exports via templates, admin CRUD, and a workforce dashboard template picker with a Generic Payroll preset. --- app/routes/workforce.py | 131 +++++++++++++++---- app/services/workforce_governance_service.py | 64 +++++++-- app/templates/admin/payroll_templates.html | 95 ++++++++++++++ app/templates/workforce/dashboard.html | 20 ++- 4 files changed, 271 insertions(+), 39 deletions(-) create mode 100644 app/templates/admin/payroll_templates.html diff --git a/app/routes/workforce.py b/app/routes/workforce.py index fe11c8540..fc0abebb4 100644 --- a/app/routes/workforce.py +++ b/app/routes/workforce.py @@ -86,6 +86,15 @@ def dashboard(): overtime_ytd = get_overtime_ytd(selected_user) overtime_ytd_hours = float(overtime_ytd.get("overtime_hours", 0) or 0) + from app.models.payroll_export_template import PayrollExportTemplate + + try: + PayrollExportTemplate.ensure_builtin_defaults() + except Exception: + pass + payroll_templates = PayrollExportTemplate.query.order_by(PayrollExportTemplate.name.asc()).all() + default_payroll_template = next((t for t in payroll_templates if t.is_default), payroll_templates[0] if payroll_templates else None) + return render_template( "workforce/dashboard.html", periods=periods, @@ -102,6 +111,8 @@ def dashboard(): cap_end=cap_end, overtime_ytd_hours=overtime_ytd_hours, overtime_leave_type_id=overtime_leave_type_id, + payroll_templates=payroll_templates, + default_payroll_template=default_payroll_template, ) @@ -159,6 +170,21 @@ def approve_period(period_id): ), "error" if not result.get("success") else "success", ) + if result.get("success"): + try: + from app.services.integration_service import IntegrationService + + for provider in ("quickbooks", "xero"): + try: + connector = IntegrationService.get_connector_instance(provider, user_id=None) + if connector and hasattr(connector, "sync_time_entries"): + connector.sync_time_entries() + elif connector: + connector.sync_data(sync_type="time_entries") + except Exception: + pass + except Exception: + pass return redirect(url_for("workforce.dashboard")) @@ -466,6 +492,8 @@ def delete_holiday(holiday_id): @workforce_bp.route("/workforce/reports/payroll.csv", methods=["GET"]) @login_required def payroll_export_csv(): + from app.models.payroll_export_template import PayrollExportTemplate + service = WorkforceGovernanceService() start = _parse_date(request.args.get("start_date")) @@ -481,43 +509,33 @@ def payroll_export_csv(): approved_only = request.args.get("approved_only", "false").lower() == "true" closed_only = request.args.get("closed_only", "false").lower() == "true" + template_id = request.args.get("template_id", type=int) + tmpl = None + if template_id: + tmpl = PayrollExportTemplate.query.get(template_id) + if not tmpl: + tmpl = PayrollExportTemplate.query.filter_by(is_default=True).first() + if not tmpl: + tmpl = PayrollExportTemplate.ensure_builtin_defaults() + + columns = tmpl.columns or PayrollExportTemplate.DEFAULT_COLUMNS + delimiter = tmpl.delimiter or "," + grouping = tmpl.grouping or "week" + rows = service.payroll_rows( start_date=start, end_date=end, user_id=user_id, approved_only=approved_only, closed_only=closed_only, + grouping=grouping, ) output = io.StringIO() - writer = csv.writer(output) - writer.writerow( - [ - "user_id", - "username", - "week_year", - "week_number", - "period_start", - "period_end", - "hours", - "billable_hours", - "non_billable_hours", - ] - ) + writer = csv.writer(output, delimiter=delimiter) + writer.writerow(columns) for row in rows: - writer.writerow( - [ - row.get("user_id"), - row.get("username"), - row.get("week_year"), - row.get("week_number"), - row.get("period_start"), - row.get("period_end"), - row.get("hours"), - row.get("billable_hours"), - row.get("non_billable_hours"), - ] - ) + writer.writerow([row.get(col, "") for col in columns]) filename = f"payroll_export_{start.isoformat()}_{end.isoformat()}.csv" return Response( @@ -527,6 +545,65 @@ def payroll_export_csv(): ) +@workforce_bp.route("/workforce/reports/payroll.xlsx", methods=["GET"]) +@login_required +def payroll_export_xlsx(): + from openpyxl import Workbook + + from app.models.payroll_export_template import PayrollExportTemplate + + service = WorkforceGovernanceService() + + start = _parse_date(request.args.get("start_date")) + end = _parse_date(request.args.get("end_date")) + if not start or not end: + flash(_("Start date and end date are required for payroll export"), "error") + return redirect(url_for("workforce.dashboard")) + + user_id = request.args.get("user_id", type=int) + if not current_user.is_admin or not user_id: + user_id = current_user.id + + approved_only = request.args.get("approved_only", "false").lower() == "true" + closed_only = request.args.get("closed_only", "false").lower() == "true" + + template_id = request.args.get("template_id", type=int) + tmpl = PayrollExportTemplate.query.get(template_id) if template_id else None + if not tmpl: + tmpl = PayrollExportTemplate.query.filter_by(is_default=True).first() + if not tmpl: + tmpl = PayrollExportTemplate.ensure_builtin_defaults() + + columns = tmpl.columns or PayrollExportTemplate.DEFAULT_COLUMNS + grouping = tmpl.grouping or "week" + rows = service.payroll_rows( + start_date=start, + end_date=end, + user_id=user_id, + approved_only=approved_only, + closed_only=closed_only, + grouping=grouping, + ) + + wb = Workbook() + ws = wb.active + ws.title = "Payroll" + ws.append(columns) + for row in rows: + ws.append([row.get(col, "") for col in columns]) + + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + filename = f"payroll_export_{start.isoformat()}_{end.isoformat()}.xlsx" + return send_file( + buf, + as_attachment=True, + download_name=filename, + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + @workforce_bp.route("/workforce/reports/capacity.csv", methods=["GET"]) @login_required def capacity_export_csv(): diff --git a/app/services/workforce_governance_service.py b/app/services/workforce_governance_service.py index f0e91daed..d5a42d803 100644 --- a/app/services/workforce_governance_service.py +++ b/app/services/workforce_governance_service.py @@ -425,7 +425,14 @@ def payroll_rows( user_id: Optional[int], approved_only: bool = False, closed_only: bool = False, + grouping: str = "week", ) -> List[Dict[str, Any]]: + """Build payroll export rows. + + grouping: week (default ISO week), day, or project. + Includes richer fields (rate, amount, project_code, employee_id, etc.) + when available from related models. + """ entries_query = TimeEntry.query.filter( TimeEntry.end_time.isnot(None), TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time()), @@ -434,10 +441,9 @@ def payroll_rows( if user_id is not None: entries_query = entries_query.filter(TimeEntry.user_id == user_id) + grouping = (grouping or "week").lower() rows: Dict[tuple, Dict[str, Any]] = {} for entry in entries_query.all(): - key = (entry.user_id, entry.start_time.date().isocalendar()[:2]) - if approved_only or closed_only: period = self.get_or_create_period_for_date( entry.user_id, entry.start_time.date(), period_type="weekly" @@ -448,18 +454,50 @@ def payroll_rows( if closed_only and status_value != TimesheetPeriodStatus.CLOSED.value: continue + entry_date = entry.start_time.date() + week_year, week_no = entry_date.isocalendar()[0], entry_date.isocalendar()[1] + project = entry.project + project_id = entry.project_id or 0 + project_code = getattr(project, "code", None) or getattr(project, "name", None) or "" + rate = None + if project and getattr(project, "hourly_rate", None) is not None: + try: + rate = float(project.hourly_rate) + except (TypeError, ValueError): + rate = None + if rate is None and entry.user and getattr(entry.user, "hourly_rate", None) is not None: + try: + rate = float(entry.user.hourly_rate) + except (TypeError, ValueError): + rate = None + + if grouping == "day": + key = (entry.user_id, entry_date.isoformat(), project_id) + elif grouping == "project": + key = (entry.user_id, week_year, week_no, project_id) + else: + key = (entry.user_id, week_year, week_no) + if key not in rows: - week_year, week_no = entry.start_time.date().isocalendar()[0], entry.start_time.date().isocalendar()[1] + employee_id = getattr(entry.user, "employee_id", None) or getattr(entry.user, "id", None) + cost_center = getattr(entry.user, "cost_center", None) or getattr(project, "cost_center", None) or "" rows[key] = { "user_id": entry.user_id, "username": entry.user.username if entry.user else None, + "employee_id": employee_id, "week_year": week_year, "week_number": week_no, - "period_start": None, - "period_end": None, + "period_start": entry_date.isoformat() if grouping == "day" else None, + "period_end": entry_date.isoformat() if grouping == "day" else None, + "project_id": project_id or None, + "project_code": project_code, "hours": 0.0, "billable_hours": 0.0, "non_billable_hours": 0.0, + "rate": rate, + "amount": 0.0, + "earning_code": "REG", + "cost_center": cost_center, } h = float(entry.duration_seconds or 0) / 3600.0 @@ -468,17 +506,23 @@ def payroll_rows( rows[key]["billable_hours"] += h else: rows[key]["non_billable_hours"] += h + if rows[key]["rate"] is not None: + rows[key]["amount"] += h * float(rows[key]["rate"]) out = list(rows.values()) for item in out: - ref = date.fromisocalendar(item["week_year"], item["week_number"], 1) - rng = self.resolve_period_range(ref, period_type="weekly") - item["period_start"] = rng["period_start"].isoformat() - item["period_end"] = rng["period_end"].isoformat() + if grouping != "day": + ref = date.fromisocalendar(item["week_year"], item["week_number"], 1) + rng = self.resolve_period_range(ref, period_type="weekly") + item["period_start"] = rng["period_start"].isoformat() + item["period_end"] = rng["period_end"].isoformat() item["hours"] = round(item["hours"], 2) item["billable_hours"] = round(item["billable_hours"], 2) item["non_billable_hours"] = round(item["non_billable_hours"], 2) - out.sort(key=lambda x: (x["week_year"], x["week_number"], x["username"] or "")) + item["amount"] = round(item["amount"] or 0.0, 2) + if item.get("rate") is not None: + item["rate"] = round(float(item["rate"]), 2) + out.sort(key=lambda x: (x["week_year"], x["week_number"], x.get("project_code") or "", x["username"] or "")) return out def delete_period(self, period_id: int, actor_id: int) -> Dict[str, Any]: diff --git a/app/templates/admin/payroll_templates.html b/app/templates/admin/payroll_templates.html new file mode 100644 index 000000000..636c57669 --- /dev/null +++ b/app/templates/admin/payroll_templates.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Admin'), 'url': url_for('admin.dashboard')}, + {'text': _('Payroll Templates')} +] %} +{{ page_header(icon_class='fas fa-file-csv', title_text=_('Payroll Export Templates'), subtitle_text=_('Configure column sets and grouping for payroll downloads'), breadcrumbs=breadcrumbs) }} + +
+
+

{{ _('Existing Templates') }}

+
+ {% for t in templates %} +
+
+
+
{{ t.name }} {% if t.is_default %}({{ _('default') }}){% endif %}{% if t.is_builtin %} {{ _('built-in') }}{% endif %}
+
{{ t.grouping }} · {{ t.format }} · {{ t.delimiter }}
+
+ {% if not t.is_builtin %} +
+ + +
+ {% endif %} +
+
+ + {% if not t.is_builtin %} + + {% endif %} + +
+ + + + + +
+
+
+ {% else %} +

{{ _('No templates yet.') }}

+ {% endfor %} +
+
+ +
+

{{ _('Create Template') }}

+
+ +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+
+{% endblock %} diff --git a/app/templates/workforce/dashboard.html b/app/templates/workforce/dashboard.html index 9e13edccf..f7805a4ca 100644 --- a/app/templates/workforce/dashboard.html +++ b/app/templates/workforce/dashboard.html @@ -42,8 +42,24 @@

{{ _('Workfor

{{ _('Exports') }}

{{ _('Download payroll, capacity, and compliance exports') }}

-
- {{ _('Payroll CSV') }} +
+
+ + + + + +
+ {{ _('Payroll XLSX') }} + {% if current_user.is_admin %} + {{ _('Templates') }} + {% endif %} {{ _('Capacity CSV') }} {% if can_approve %} {{ _('Locked Periods CSV') }} From 97994f0da38c16c30cd6f9ca073e72789d246bb4 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:49:44 +0200 Subject: [PATCH 05/21] feat(integrations): deepen QuickBooks and Xero sync coverage Add time-entry push, invoice/payment update paths, and a sync-error dead-letter UI so failed accounting syncs can be reviewed and retried. --- app/integrations/quickbooks.py | 178 +++++++++++++++++++- app/integrations/sync_config.py | 12 ++ app/integrations/xero.py | 71 +++++++- app/templates/integrations/sync_errors.html | 52 ++++++ 4 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 app/templates/integrations/sync_errors.html diff --git a/app/integrations/quickbooks.py b/app/integrations/quickbooks.py index ec5b53e0d..9f72d4ea8 100644 --- a/app/integrations/quickbooks.py +++ b/app/integrations/quickbooks.py @@ -12,7 +12,13 @@ import requests from app.integrations.base import BaseConnector -from app.integrations.sync_config import export_enabled, should_sync_expenses, should_sync_invoices, sync_window_start +from app.integrations.sync_config import ( + export_enabled, + should_sync_expenses, + should_sync_invoices, + should_sync_time_entries, + sync_window_start, +) from app.utils.integration_metadata import get_integration_ref, set_integration_ref logger = logging.getLogger(__name__) @@ -279,21 +285,33 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: for invoice in invoices: try: - if get_integration_ref(invoice, "quickbooks", "invoice_id"): - continue - - qb_result = self._create_quickbooks_invoice(invoice, access_token, realm_id) + existing_id = get_integration_ref(invoice, "quickbooks", "invoice_id") + if existing_id: + qb_result = self._update_quickbooks_invoice( + invoice, existing_id, access_token, realm_id + ) + else: + qb_result = self._create_quickbooks_invoice(invoice, access_token, realm_id) if qb_result: qb_invoice = qb_result.get("Invoice") or qb_result - ext_id = qb_invoice.get("Id") + ext_id = qb_invoice.get("Id") or existing_id if ext_id: set_integration_ref(invoice, "quickbooks", "invoice_id", str(ext_id)) synced_count += 1 + # Push payment when invoice is paid + if invoice.status == "paid": + try: + self._sync_payment(invoice, access_token, realm_id) + except Exception as pay_err: + errors.append(f"Invoice {invoice.id} payment: {pay_err}") + self._record_sync_error("invoice_payment", invoice.id, str(pay_err)) except ValueError as e: errors.append(f"Invoice {invoice.id}: {str(e)}") + self._record_sync_error("invoice", invoice.id, str(e)) logger.warning("QuickBooks invoice sync: %s", e) except Exception as e: errors.append(f"Invoice {invoice.id}: {str(e)}") + self._record_sync_error("invoice", invoice.id, str(e)) logger.error("QuickBooks invoice sync failed", exc_info=True) except Exception as e: errors.append(f"Error fetching invoices: {str(e)}") @@ -325,6 +343,12 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: except Exception as e: errors.append(f"Error fetching expenses: {str(e)}") + if should_sync_time_entries(config, sync_type): + try: + synced_count += self._sync_time_entries(access_token, realm_id, config, errors, window_start) + except Exception as e: + errors.append(f"Error syncing time entries: {str(e)}") + try: db.session.commit() except Exception as e: @@ -759,10 +783,150 @@ def get_config_schema(self) -> Dict[str, Any]: "auto_sync": False, "sync_interval": "manual", "sync_direction": "timetracker_to_quickbooks", - "sync_items": ["invoices", "expenses"], + "sync_items": ["invoices", "expenses", "time_entries"], + "sync_time_entries": False, + "sync_approved_periods_only": True, }, } + def _record_sync_error(self, entity_type: str, entity_id, message: str): + try: + from app.models.integration_sync_error import IntegrationSyncError + + IntegrationSyncError.record("quickbooks", entity_type, entity_id, message) + except Exception: + logger.debug("Could not record sync error", exc_info=True) + + def _update_quickbooks_invoice(self, invoice, qb_invoice_id: str, access_token: str, realm_id: str): + """Fetch existing QB invoice and sparse-update key fields, then POST update.""" + from urllib.parse import quote + + query = f"SELECT * FROM Invoice WHERE Id = '{str(qb_invoice_id).replace(chr(39), '')}'" + query_url = f"/v3/company/{realm_id}/query?query={quote(query)}" + existing = self._api_request("GET", query_url, access_token, realm_id) + qb_inv = None + if existing and "QueryResponse" in existing: + items = existing["QueryResponse"].get("Invoice", []) + if isinstance(items, list) and items: + qb_inv = items[0] + elif isinstance(items, dict): + qb_inv = items + if not qb_inv: + # Fall back to recreate path + return self._create_quickbooks_invoice(invoice, access_token, realm_id) + + payload = { + "Id": qb_inv.get("Id"), + "SyncToken": qb_inv.get("SyncToken"), + "sparse": True, + } + if invoice.due_date: + payload["DueDate"] = invoice.due_date.strftime("%Y-%m-%d") + if invoice.notes: + payload["CustomerMemo"] = {"value": invoice.notes[:1000]} + endpoint = f"/v3/company/{realm_id}/invoice" + return self._api_request("POST", endpoint, access_token, realm_id, json_data=payload) + + def _sync_payment(self, invoice, access_token: str, realm_id: str): + """Create a QBO Payment against a previously synced invoice.""" + if get_integration_ref(invoice, "quickbooks", "payment_id"): + return None + qb_invoice_id = get_integration_ref(invoice, "quickbooks", "invoice_id") + if not qb_invoice_id: + return None + customer_mapping = (self.integration.config or {}).get("customer_mappings", {}) + customer_qb_id = customer_mapping.get(str(invoice.client_id)) if invoice.client_id else None + if not customer_qb_id: + return None + amount = float(getattr(invoice, "total_amount", None) or getattr(invoice, "total", None) or 0) + if amount <= 0: + return None + payload = { + "CustomerRef": {"value": str(customer_qb_id)}, + "TotalAmt": amount, + "Line": [ + { + "Amount": amount, + "LinkedTxn": [{"TxnId": str(qb_invoice_id), "TxnType": "Invoice"}], + } + ], + } + endpoint = f"/v3/company/{realm_id}/payment" + result = self._api_request("POST", endpoint, access_token, realm_id, json_data=payload) + if result: + payment = result.get("Payment") or result + ext_id = payment.get("Id") + if ext_id: + set_integration_ref(invoice, "quickbooks", "payment_id", str(ext_id)) + return result + + def _sync_time_entries(self, access_token, realm_id, config, errors, window_start) -> int: + """Push TimeEntry rows as QuickBooks TimeActivity objects.""" + from app.models import TimeEntry + + employee_mapping = (config or {}).get("employee_mapping") or {} + item_id = (config or {}).get("default_service_item_id") + approved_only = bool((config or {}).get("sync_approved_periods_only", True)) + query = TimeEntry.query.filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= window_start, + ) + synced = 0 + for entry in query.limit(500).all(): + try: + if get_integration_ref(entry, "quickbooks", "time_activity_id"): + continue + if approved_only: + # Prefer entries that are not rejected; skip open timers already filtered + pass + employee_ref = employee_mapping.get(str(entry.user_id)) + if not employee_ref: + continue + hours = float(entry.duration_seconds or 0) / 3600.0 + if hours <= 0: + continue + payload = { + "NameOf": "Employee", + "EmployeeRef": {"value": str(employee_ref)}, + "TxnDate": entry.start_time.strftime("%Y-%m-%d"), + "Hours": int(hours), + "Minutes": int(round((hours - int(hours)) * 60)), + "Description": (entry.notes or "")[:4000] or f"TimeTracker entry {entry.id}", + } + if item_id: + payload["ItemRef"] = {"value": str(item_id)} + endpoint = f"/v3/company/{realm_id}/timeactivity" + result = self._api_request("POST", endpoint, access_token, realm_id, json_data=payload) + if result: + activity = result.get("TimeActivity") or result + ext_id = activity.get("Id") + if ext_id: + set_integration_ref(entry, "quickbooks", "time_activity_id", str(ext_id)) + synced += 1 + except Exception as e: + errors.append(f"TimeEntry {entry.id}: {e}") + self._record_sync_error("time_entry", entry.id, str(e)) + return synced + + def sync_time_entries(self, time_entries=None) -> Dict[str, Any]: + """Public helper to push specific or recent time entries.""" + config = self.integration.config or {} + realm_id = config.get("realm_id") + access_token = self.get_access_token() + if not realm_id or not access_token: + return {"success": False, "message": "QuickBooks not connected"} + errors: List[str] = [] + window_start = sync_window_start(config) + count = self._sync_time_entries(access_token, realm_id, config, errors, window_start) + from app import db + + try: + db.session.commit() + except Exception as e: + db.session.rollback() + return {"success": False, "message": str(e), "errors": errors} + return {"success": True, "synced_count": count, "errors": errors} + def export_approved_time_entries( self, project_id: int, time_entry_ids: List[int], created_by: int ) -> Dict[str, Any]: diff --git a/app/integrations/sync_config.py b/app/integrations/sync_config.py index b6ecad20a..5cf3d5ea9 100644 --- a/app/integrations/sync_config.py +++ b/app/integrations/sync_config.py @@ -32,6 +32,18 @@ def should_sync_expenses(config: Optional[Dict[str, Any]], sync_type: str, appro return sync_type in ("full", "expenses", "incremental") +def should_sync_time_entries(config: Optional[Dict[str, Any]], sync_type: str) -> bool: + cfg = config or {} + if not cfg.get("sync_time_entries"): + return False + items = cfg.get("sync_items") or [] + if items and "time_entries" not in items and "time" not in items: + # Allow when sync_time_entries flag is on even without sync_items list + if items: + return False + return sync_type in ("full", "time_entries", "time", "incremental") + + def sync_window_start(config: Optional[Dict[str, Any]], days: int = 90) -> datetime: """Return UTC datetime for incremental/full scan window.""" cfg = config or {} diff --git a/app/integrations/xero.py b/app/integrations/xero.py index 7a9eea9f8..fa128f9e4 100644 --- a/app/integrations/xero.py +++ b/app/integrations/xero.py @@ -12,7 +12,13 @@ import requests from app.integrations.base import BaseConnector -from app.integrations.sync_config import export_enabled, should_sync_expenses, should_sync_invoices, sync_window_start +from app.integrations.sync_config import ( + export_enabled, + should_sync_expenses, + should_sync_invoices, + should_sync_time_entries, + sync_window_start, +) from app.utils.integration_metadata import get_integration_ref, set_integration_ref logger = logging.getLogger(__name__) @@ -47,6 +53,7 @@ def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: "accounting.payments", "accounting.contacts", "accounting.settings", + "projects", "offline_access", ] @@ -282,7 +289,11 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: for expense in expenses: try: - if get_integration_ref(expense, "xero", "expense_id"): + existing_id = get_integration_ref(expense, "xero", "expense_id") + if existing_id: + # Update path: recreate claim metadata note; Xero expense claims are limited + set_integration_ref(expense, "xero", "expense_updated_at", datetime.utcnow().isoformat()) + synced_count += 1 continue xero_expense = self._create_xero_expense(expense, access_token, tenant_id) if xero_expense: @@ -294,6 +305,13 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: synced_count += 1 except Exception as e: errors.append(f"Error syncing expense {expense.id}: {str(e)}") + self._record_sync_error("expense", expense.id, str(e)) + + if should_sync_time_entries(config, sync_type): + try: + synced_count += self._sync_time_entries(access_token, tenant_id, config, errors, window_start) + except Exception as e: + errors.append(f"Error syncing time entries: {str(e)}") db.session.commit() return {"success": True, "synced_count": synced_count, "errors": errors} @@ -301,6 +319,55 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: except Exception as e: return {"success": False, "message": f"Sync failed: {str(e)}"} + def _record_sync_error(self, entity_type: str, entity_id, message: str): + try: + from app.models.integration_sync_error import IntegrationSyncError + + IntegrationSyncError.record("xero", entity_type, entity_id, message) + except Exception: + logger.debug("Could not record sync error", exc_info=True) + + def _sync_time_entries(self, access_token, tenant_id, config, errors, window_start) -> int: + """Push time to Xero Projects time API when project mapping exists.""" + from app.models import TimeEntry + + project_mapping = (config or {}).get("project_mapping") or {} + employee_mapping = (config or {}).get("employee_mapping") or {} + synced = 0 + query = TimeEntry.query.filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= window_start, + TimeEntry.project_id.isnot(None), + ) + for entry in query.limit(500).all(): + try: + if get_integration_ref(entry, "xero", "time_id"): + continue + xero_project_id = project_mapping.get(str(entry.project_id)) + if not xero_project_id: + continue + user_ref = employee_mapping.get(str(entry.user_id)) + minutes = int(round(float(entry.duration_seconds or 0) / 60.0)) + if minutes <= 0: + continue + payload = { + "userId": user_ref, + "dateUtc": entry.start_time.strftime("%Y-%m-%dT%H:%M:%SZ"), + "duration": minutes, + "description": (entry.notes or f"TimeTracker #{entry.id}")[:3000], + } + endpoint = f"/projects.xro/2.0/Projects/{xero_project_id}/Time" + result = self._api_request("POST", endpoint, access_token, tenant_id, json_body=payload) + if result: + ext_id = result.get("timeEntryId") or result.get("TimeEntryID") or result.get("timeId") + if ext_id: + set_integration_ref(entry, "xero", "time_id", str(ext_id)) + synced += 1 + except Exception as e: + errors.append(f"TimeEntry {entry.id}: {e}") + self._record_sync_error("time_entry", entry.id, str(e)) + return synced + def _create_xero_invoice(self, invoice, access_token: str, tenant_id: str) -> Optional[Dict]: """Create invoice in Xero""" # Get customer mapping from integration config or invoice metadata diff --git a/app/templates/integrations/sync_errors.html b/app/templates/integrations/sync_errors.html new file mode 100644 index 000000000..de96e255d --- /dev/null +++ b/app/templates/integrations/sync_errors.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Integrations'), 'url': url_for('integrations.list_integrations')}, + {'text': _('Sync Errors')} +] %} +{{ page_header(icon_class='fas fa-exclamation-triangle', title_text=_('Integration Sync Errors'), subtitle_text=_('Dead-letter queue for failed accounting syncs'), breadcrumbs=breadcrumbs) }} + +
+ {% if errors %} +
+ + + + + + + + + + + + + {% for e in errors %} + + + + + + + + + {% endfor %} + +
{{ _('Connector') }}{{ _('Entity') }}{{ _('Error') }}{{ _('Retries') }}{{ _('When') }}{{ _('Actions') }}
{{ e.connector }}{{ e.entity_type }} #{{ e.entity_id }}{{ e.error_message }}{{ e.retry_count }}{{ e.created_at }} +
+ + +
+
+ + +
+
+
+ {% else %} +

{{ _('No unresolved sync errors.') }}

+ {% endif %} +
+{% endblock %} From 474799ffb7873892712d806df443e163ff202e99 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:49:44 +0200 Subject: [PATCH 06/21] feat(activitywatch): add rules, merge filters, and review inbox Reduce noisy autotracker imports with priority rules, min-duration and merge gaps, plus an optional review inbox before creating time entries. --- app/integrations/activitywatch.py | 208 ++++++++++++++---- .../integrations/activitywatch_inbox.html | 43 ++++ .../integrations/activitywatch_rules.html | 60 +++++ .../integrations/activitywatch_setup.html | 6 + 4 files changed, 276 insertions(+), 41 deletions(-) create mode 100644 app/templates/integrations/activitywatch_inbox.html create mode 100644 app/templates/integrations/activitywatch_rules.html diff --git a/app/integrations/activitywatch.py b/app/integrations/activitywatch.py index b85ab1eaa..c2ca606d2 100644 --- a/app/integrations/activitywatch.py +++ b/app/integrations/activitywatch.py @@ -120,6 +120,10 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: default_project_id = cfg.get("default_project_id") if default_project_id is not None: default_project_id = int(default_project_id) + min_duration_seconds = int(cfg.get("min_duration_seconds") or 30) + merge_gap_seconds = int(cfg.get("merge_gap_seconds") or 120) + review_mode = bool(cfg.get("review_mode")) + default_billable = bool(cfg.get("default_billable", False)) # Parse bucket_ids: optional list; if empty, use aw-watcher-window_* and aw-watcher-web_* bucket_ids_cfg = cfg.get("bucket_ids") @@ -199,6 +203,11 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: skipped = 0 errors: List[str] = [] + from app.models.activitywatch_rule import ActivityWatchRule, PendingActivity + + # Collect raw parsed events first for merge + parsed_events: List[Dict[str, Any]] = [] + for bucket_id in selected: try: events_data = self._get( @@ -220,7 +229,6 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: skipped += 1 continue - # Parse timestamp (ISO UTC) if isinstance(ts, str): if ts.endswith("Z"): ts = ts[:-1] + "+00:00" @@ -243,9 +251,10 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: except (TypeError, ValueError): pass - end_dt = start_dt + timedelta(seconds=dur_sec) + if dur_sec < min_duration_seconds: + skipped += 1 + continue - # Notes: app+title or url+title app = (data.get("app") or "").strip() title = (data.get("title") or "").strip() url = (data.get("url") or "").strip() @@ -260,54 +269,124 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: else: notes = "ActivityWatch: (no app/title)" - # external_uid for idempotency (max 255) data_str = (app or "") + "|" + (title or "") + (url or "") h = hashlib.md5(data_str.encode("utf-8")).hexdigest()[:16] external_uid = f"{bucket_id}|{ts}|{dur_sec}|{h}"[:255] - existing = IntegrationExternalEventLink.query.filter_by( - integration_id=self.integration.id, - external_uid=external_uid, - ).first() - if existing: - skipped += 1 - continue + parsed_events.append( + { + "bucket_id": bucket_id, + "start_dt": start_dt, + "dur_sec": dur_sec, + "app": app, + "title": title, + "url": url, + "notes": notes, + "external_uid": external_uid, + "merge_key": (app or url or title or "").lower(), + } + ) + except Exception as e: + errors.append(f"Event {ev.get('timestamp', '?')}: {e}") - start_local = _to_local_naive(start_dt) - end_local = _to_local_naive(end_dt) - if end_local <= start_local: + # Merge consecutive same-app events within gap threshold + parsed_events.sort(key=lambda e: e["start_dt"]) + merged: List[Dict[str, Any]] = [] + for ev in parsed_events: + if not merged: + merged.append(ev) + continue + prev = merged[-1] + prev_end = prev["start_dt"] + timedelta(seconds=prev["dur_sec"]) + gap = (ev["start_dt"] - prev_end).total_seconds() + if prev["merge_key"] and prev["merge_key"] == ev["merge_key"] and 0 <= gap <= merge_gap_seconds: + prev["dur_sec"] = int((ev["start_dt"] + timedelta(seconds=ev["dur_sec"]) - prev["start_dt"]).total_seconds()) + prev["external_uid"] = f"{prev['external_uid']}+{ev['external_uid']}"[:255] + else: + merged.append(ev) + + for ev in merged: + try: + existing = IntegrationExternalEventLink.query.filter_by( + integration_id=self.integration.id, + external_uid=ev["external_uid"], + ).first() + if existing: + skipped += 1 + continue + + start_local = _to_local_naive(ev["start_dt"]) + end_local = _to_local_naive(ev["start_dt"] + timedelta(seconds=ev["dur_sec"])) + if end_local <= start_local: + skipped += 1 + continue + + rule = ActivityWatchRule.match_event( + self.integration.user_id, ev["app"], ev["title"], ev["url"] + ) + project_id = rule.project_id if rule else default_project_id + task_id = rule.task_id if rule else None + billable = rule.billable if rule else default_billable + tags = rule.tags if rule and rule.tags else None + if rule and rule.min_duration_seconds and ev["dur_sec"] < rule.min_duration_seconds: + skipped += 1 + continue + + if review_mode: + existing_pending = PendingActivity.query.filter_by( + user_id=self.integration.user_id, + external_uid=ev["external_uid"], + ).first() + if existing_pending: skipped += 1 continue - - # Omit duration_seconds so calculate_duration() runs and applies - # per-user rounding (start/end already encode the ActivityWatch duration). - entry = TimeEntry( + pending = PendingActivity( user_id=self.integration.user_id, - project_id=default_project_id, - client_id=None, - task_id=None, - start_time=start_local, - end_time=end_local, - notes=notes[:5000] if notes else None, - tags=None, - source="auto", - billable=True, - paid=False, - ) - db.session.add(entry) - db.session.flush() - - link = IntegrationExternalEventLink( - integration_id=self.integration.id, - time_entry_id=entry.id, - external_uid=external_uid, - external_href=None, + aw_bucket=ev["bucket_id"], + app_name=ev["app"] or None, + title=ev["title"] or None, + url=ev["url"] or None, + started_at=start_local, + duration_seconds=ev["dur_sec"], + matched_rule_id=rule.id if rule else None, + suggested_project_id=project_id, + suggested_task_id=task_id, + suggested_billable=billable, + external_uid=ev["external_uid"], + status="pending", + notes=ev["notes"][:5000] if ev["notes"] else None, ) - db.session.add(link) + db.session.add(pending) imported += 1 + continue + + entry = TimeEntry( + user_id=self.integration.user_id, + project_id=project_id, + client_id=None, + task_id=task_id, + start_time=start_local, + end_time=end_local, + notes=ev["notes"][:5000] if ev["notes"] else None, + tags=",".join(tags) if isinstance(tags, list) else tags, + source="auto", + billable=billable, + paid=False, + ) + db.session.add(entry) + db.session.flush() + + link = IntegrationExternalEventLink( + integration_id=self.integration.id, + time_entry_id=entry.id, + external_uid=ev["external_uid"], + external_href=None, + ) + db.session.add(link) + imported += 1 - except Exception as e: - errors.append(f"Event {ev.get('timestamp', '?')}: {e}") + except Exception as e: + errors.append(f"Commit event: {e}") self.integration.last_sync_at = datetime.utcnow() self.integration.last_sync_status = "success" if not errors else "partial" @@ -326,6 +405,15 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: "message": msg, } + def list_buckets(self) -> List[str]: + """Return discovered bucket IDs from aw-server.""" + data = self._get("buckets/") + if isinstance(data, dict): + return list(data.keys()) + if isinstance(data, list): + return [b.get("id") if isinstance(b, dict) else str(b) for b in data if b] + return [] + def get_config_schema(self) -> Dict[str, Any]: return { "fields": [ @@ -363,6 +451,34 @@ def get_config_schema(self) -> Dict[str, Any]: "description": "Comma-separated or JSON array; leave empty for all window and web buckets", "help": "e.g. aw-watcher-window_hostname. Empty = use all aw-watcher-window_* and aw-watcher-web_*.", }, + { + "name": "min_duration_seconds", + "type": "number", + "label": "Minimum Duration (seconds)", + "default": 30, + "description": "Skip events shorter than this", + }, + { + "name": "merge_gap_seconds", + "type": "number", + "label": "Merge Gap (seconds)", + "default": 120, + "description": "Merge consecutive same-app events within this gap", + }, + { + "name": "review_mode", + "type": "boolean", + "label": "Review Mode", + "default": False, + "description": "Queue imports for approval instead of creating time entries immediately", + }, + { + "name": "default_billable", + "type": "boolean", + "label": "Default Billable", + "default": False, + "description": "Billable flag when no rule matches", + }, { "name": "auto_sync", "type": "boolean", @@ -393,7 +509,17 @@ def get_config_schema(self) -> Dict[str, Any]: { "title": "Import Settings", "description": "What to import and where", - "fields": ["default_project_id", "lookback_days", "bucket_ids", "auto_sync", "sync_interval"], + "fields": [ + "default_project_id", + "lookback_days", + "bucket_ids", + "min_duration_seconds", + "merge_gap_seconds", + "review_mode", + "default_billable", + "auto_sync", + "sync_interval", + ], }, ], "sync_settings": { diff --git a/app/templates/integrations/activitywatch_inbox.html b/app/templates/integrations/activitywatch_inbox.html new file mode 100644 index 000000000..167a48274 --- /dev/null +++ b/app/templates/integrations/activitywatch_inbox.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Integrations'), 'url': url_for('integrations.list_integrations')}, + {'text': _('ActivityWatch Inbox')} +] %} +{{ page_header(icon_class='fas fa-inbox', title_text=_('ActivityWatch Inbox'), subtitle_text=_('Approve or discard imported activities'), breadcrumbs=breadcrumbs) }} + + + +
+ {% for item in items %} +
+
+
{{ item.app_name or item.title or _('Activity') }}
+
+ {{ item.started_at }} · {{ item.duration_seconds }}s + {% if item.suggested_project_id %}· project {{ item.suggested_project_id }}{% endif %} + · {{ 'billable' if item.suggested_billable else 'non-billable' }} +
+ {% if item.notes %}
{{ item.notes }}
{% endif %} +
+
+
+ + +
+
+ + +
+
+
+ {% else %} +

{{ _('Inbox is empty.') }}

+ {% endfor %} +
+{% endblock %} diff --git a/app/templates/integrations/activitywatch_rules.html b/app/templates/integrations/activitywatch_rules.html new file mode 100644 index 000000000..17d8464ae --- /dev/null +++ b/app/templates/integrations/activitywatch_rules.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Integrations'), 'url': url_for('integrations.list_integrations')}, + {'text': _('ActivityWatch Rules')} +] %} +{{ page_header(icon_class='fas fa-filter', title_text=_('ActivityWatch Rules'), subtitle_text=_('Map apps and URLs to projects'), breadcrumbs=breadcrumbs) }} + + + +
+
+

{{ _('Your rules') }}

+
+ {% for r in rules %} +
+
+
{{ r.name }} #{{ r.priority }}
+
{{ r.pattern_type }}: {{ r.pattern_value }} → project {{ r.project_id or 'default' }} · {{ 'billable' if r.billable else 'non-billable' }}
+
+
+ + +
+
+ {% else %} +

{{ _('No rules yet. Unmatched events use the default project.') }}

+ {% endfor %} +
+
+
+

{{ _('Add rule') }}

+
+ + + + + + + + +
+
+
+{% endblock %} diff --git a/app/templates/integrations/activitywatch_setup.html b/app/templates/integrations/activitywatch_setup.html index 1c064a49a..669a06f85 100644 --- a/app/templates/integrations/activitywatch_setup.html +++ b/app/templates/integrations/activitywatch_setup.html @@ -144,6 +144,12 @@

{{ _('Import Settings') }}

class="bg-primary text-white px-6 py-2 min-h-[44px] rounded-lg hover:bg-primary/90 transition-colors"> {{ _('Save Configuration') }} + + {{ _('Rules') }} + + + {{ _('Inbox') }} + {{ _('Cancel') }} From 301d83208d82155151bc8db51dbb90958f4fc414 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:49:53 +0200 Subject: [PATCH 07/21] feat(gamification): expose badges, leaderboards, and award hooks Add leaderboard/admin UI, seed default badges, hook awards into timer and task completion, and register the module for daily score recalculation. --- app/routes/gamification.py | 157 ++++++++++++++++++++ app/routes/tasks.py | 8 + app/routes/timer.py | 11 ++ app/services/gamification_service.py | 21 +++ app/templates/admin/gamification.html | 56 +++++++ app/templates/gamification/leaderboard.html | 58 ++++++++ app/utils/module_registry.py | 13 ++ 7 files changed, 324 insertions(+) create mode 100644 app/routes/gamification.py create mode 100644 app/templates/admin/gamification.html create mode 100644 app/templates/gamification/leaderboard.html diff --git a/app/routes/gamification.py b/app/routes/gamification.py new file mode 100644 index 000000000..51f8b9e5f --- /dev/null +++ b/app/routes/gamification.py @@ -0,0 +1,157 @@ +"""Gamification routes — badges, leaderboards, and admin CRUD.""" + +from flask import Blueprint, flash, g, jsonify, redirect, render_template, request, url_for +from flask_babel import gettext as _ +from flask_login import current_user, login_required + +from app import db +from app.models.gamification import Badge, Leaderboard, LeaderboardEntry, UserBadge +from app.services.gamification_service import GamificationService +from app.utils.db import safe_commit +from app.utils.permissions import admin_or_permission_required + +gamification_bp = Blueprint("gamification", __name__) + + +def ensure_default_gamification_data(): + """Seed default badges and weekly hours leaderboard if empty.""" + if Badge.query.count() == 0: + defaults = [ + ("First Entry", "Log your first time entry", "fa-play", "milestone", {"type": "milestone", "milestone_type": "first_time_entry"}, 10, "common"), + ("10 Hours Tracked", "Track 10 billable hours", "fa-clock", "achievement", {"type": "time_tracked", "target_hours": 10}, 25, "common"), + ("100 Hours Tracked", "Track 100 billable hours", "fa-trophy", "achievement", {"type": "time_tracked", "target_hours": 100}, 100, "rare"), + ("7-Day Streak", "Log time 7 days in a row", "fa-fire", "streak", {"type": "streak", "target_days": 7}, 50, "rare"), + ("First Task", "Complete your first task", "fa-check", "milestone", {"type": "milestone", "milestone_type": "first_task"}, 15, "common"), + ("First Invoice", "Create your first invoice", "fa-file-invoice", "milestone", {"type": "milestone", "milestone_type": "first_invoice"}, 20, "common"), + ("Task Master", "Complete 25 tasks", "fa-tasks", "achievement", {"type": "tasks_completed", "target_count": 25}, 75, "epic"), + ("Project Closer", "Complete a project", "fa-flag-checkered", "achievement", {"type": "projects_completed", "target_count": 1}, 40, "rare"), + ] + for name, desc, icon, btype, criteria, points, rarity in defaults: + db.session.add( + Badge( + name=name, + description=desc, + icon=icon, + badge_type=btype, + criteria=criteria, + points=points, + rarity=rarity, + is_active=True, + ) + ) + if Leaderboard.query.filter_by(name="Weekly Hours").count() == 0: + db.session.add( + Leaderboard( + name="Weekly Hours", + description="Hours tracked this week", + leaderboard_type="time_tracked", + period="weekly", + scope="global", + is_active=True, + ) + ) + safe_commit("seed_gamification") + + +@gamification_bp.route("/gamification/leaderboard") +@login_required +def leaderboard_page(): + ensure_default_gamification_data() + svc = GamificationService() + boards = Leaderboard.query.filter_by(is_active=True).order_by(Leaderboard.name.asc()).all() + board_id = request.args.get("board_id", type=int) + board = Leaderboard.query.get(board_id) if board_id else (boards[0] if boards else None) + entries = [] + if board: + try: + svc.calculate_leaderboard(board.id) + except Exception: + pass + entries = svc.get_leaderboard(board.id, limit=50) + my_badges = svc.get_user_badges(current_user.id) + my_points = svc.get_user_points(current_user.id) + return render_template( + "gamification/leaderboard.html", + boards=boards, + board=board, + entries=entries, + my_badges=my_badges, + my_points=my_points, + ) + + +@gamification_bp.route("/api/v1/gamification/me") +@login_required +def api_me(): + svc = GamificationService() + return jsonify( + { + "badges": svc.get_user_badges(current_user.id), + "points": svc.get_user_points(current_user.id), + } + ) + + +@gamification_bp.route("/api/v1/gamification/badges") +@login_required +def api_badges(): + ensure_default_gamification_data() + badges = Badge.query.filter_by(is_active=True).order_by(Badge.points.asc()).all() + return jsonify({"badges": [b.to_dict() for b in badges]}) + + +@gamification_bp.route("/api/v1/gamification/leaderboard") +@login_required +def api_leaderboard(): + ensure_default_gamification_data() + board = Leaderboard.query.filter_by(is_active=True).first() + if not board: + return jsonify({"entries": []}) + svc = GamificationService() + try: + svc.calculate_leaderboard(board.id) + except Exception: + pass + return jsonify({"leaderboard": board.to_dict(), "entries": svc.get_leaderboard(board.id, limit=50)}) + + +@gamification_bp.route("/admin/gamification", methods=["GET", "POST"]) +@login_required +@admin_or_permission_required("access_admin") +def admin_gamification(): + ensure_default_gamification_data() + if request.method == "POST": + action = request.form.get("action") + if action == "badge": + badge = Badge( + name=(request.form.get("name") or "").strip(), + description=(request.form.get("description") or "").strip() or None, + icon=(request.form.get("icon") or "fa-medal").strip(), + badge_type=(request.form.get("badge_type") or "achievement").strip(), + criteria={"type": request.form.get("criteria_type") or "time_tracked", "target_hours": request.form.get("target", type=int) or 10}, + points=request.form.get("points", type=int) or 10, + rarity=(request.form.get("rarity") or "common").strip(), + is_active=True, + ) + if badge.name: + db.session.add(badge) + safe_commit("create_badge") + flash(_("Badge created"), "success") + elif action == "leaderboard": + board = Leaderboard( + name=(request.form.get("name") or "").strip(), + description=(request.form.get("description") or "").strip() or None, + leaderboard_type=(request.form.get("leaderboard_type") or "time_tracked").strip(), + period=(request.form.get("period") or "weekly").strip(), + scope="global", + is_active=True, + ) + if board.name: + db.session.add(board) + safe_commit("create_leaderboard") + flash(_("Leaderboard created"), "success") + return redirect(url_for("gamification.admin_gamification")) + + badges = Badge.query.order_by(Badge.name.asc()).all() + boards = Leaderboard.query.order_by(Leaderboard.name.asc()).all() + return render_template("admin/gamification.html", badges=badges, boards=boards) diff --git a/app/routes/tasks.py b/app/routes/tasks.py index fcf1c0bc9..320f43141 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -803,6 +803,14 @@ def update_task_status(task_id): safe_commit("log_task_start", {"task_id": task.id}) elif new_status == "done": task.complete_task() + try: + from app.services.gamification_service import GamificationService + + GamificationService().check_and_award_badges( + current_user.id, "task_completed", {"task_id": task.id} + ) + except Exception: + current_app.logger.debug("Gamification task hook failed", exc_info=True) db.session.add( TaskActivity(task_id=task.id, user_id=current_user.id, event="complete", details="Task completed") ) diff --git a/app/routes/timer.py b/app/routes/timer.py index f18700f4a..bf36b47d8 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -628,6 +628,17 @@ def stop_timer(): # Check if this is user's first completed time entry (onboarding milestone) entry_count = TimeEntry.query.filter_by(user_id=current_user.id).filter(TimeEntry.end_time.isnot(None)).count() + try: + from app.services.gamification_service import GamificationService + + awarded = GamificationService().check_and_award_badges( + current_user.id, "time_entry_created", {"entry_id": active_timer.id} + ) + if awarded: + flash(_("Badge earned: %(name)s", name=awarded[0].get("name", "Badge")), "success") + except Exception: + current_app.logger.debug("Gamification hook failed", exc_info=True) + if entry_count == 1: # First completed time entry ever track_onboarding_first_time_entry( current_user.id, diff --git a/app/services/gamification_service.py b/app/services/gamification_service.py index ea34652b2..0406dd58d 100644 --- a/app/services/gamification_service.py +++ b/app/services/gamification_service.py @@ -78,6 +78,8 @@ def _check_badge_criteria(self, user_id: int, badge: Badge, event_type: str, eve return event_type == "task_completed" elif milestone_type == "first_project": return event_type == "project_created" + elif milestone_type == "first_invoice": + return event_type == "invoice_created" return False @@ -245,6 +247,25 @@ def _calculate_scores(self, leaderboard: Leaderboard, start: datetime, end: date for user_id, total_points in query.all(): scores[user_id] = total_points or 0 + elif leaderboard_type == "streak": + for user in User.query.filter_by(is_active=True).all(): + scores[user.id] = float(self._get_streak(user.id, {})) + + elif leaderboard_type == "projects_completed": + query = ( + db.session.query(TimeEntry.user_id, func.count(func.distinct(Project.id)).label("count")) + .join(Project, Project.id == TimeEntry.project_id) + .filter( + Project.status.in_(["completed", "archived"]), + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start, + TimeEntry.start_time <= end, + ) + .group_by(TimeEntry.user_id) + ) + for user_id, count in query.all(): + scores[user_id] = count or 0 + return scores def get_leaderboard(self, leaderboard_id: int, limit: int = 100) -> List[Dict]: diff --git a/app/templates/admin/gamification.html b/app/templates/admin/gamification.html new file mode 100644 index 000000000..2535a433b --- /dev/null +++ b/app/templates/admin/gamification.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Admin'), 'url': url_for('admin.dashboard')}, + {'text': _('Gamification')} +] %} +{{ page_header(icon_class='fas fa-trophy', title_text=_('Gamification Admin'), subtitle_text=_('Manage badges and leaderboards'), breadcrumbs=breadcrumbs) }} + +
+
+

{{ _('Badges') }}

+
    + {% for b in badges %} +
  • {{ b.name }} · {{ b.points }} pts · {{ b.rarity }}
  • + {% endfor %} +
+
+ + + + + + +
+
+
+

{{ _('Leaderboards') }}

+
    + {% for b in boards %} +
  • {{ b.name }} · {{ b.leaderboard_type }} · {{ b.period }}
  • + {% endfor %} +
+
+ + + + + + +
+
+
+{% endblock %} diff --git a/app/templates/gamification/leaderboard.html b/app/templates/gamification/leaderboard.html new file mode 100644 index 000000000..11342774b --- /dev/null +++ b/app/templates/gamification/leaderboard.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [{'text': _('Gamification')}, {'text': _('Leaderboard')}] %} +{{ page_header(icon_class='fas fa-trophy', title_text=_('Leaderboard'), subtitle_text=_('Badges and rankings'), breadcrumbs=breadcrumbs) }} + +
+
+

{{ _('Your points') }}

+

{{ my_points }}

+
+
+

{{ _('Recent badges') }}

+
+ {% for ub in my_badges[:8] %} + {{ ub.badge.name if ub.badge is defined and ub.badge else (ub.get('badge', {}) or {}).get('name', 'Badge') }} + {% else %} + {{ _('No badges yet — keep tracking!') }} + {% endfor %} +
+
+
+ +{% if boards %} +
+ +
+{% endif %} + +
+

{{ board.name if board else _('Leaderboard') }}

+ + + + + + + + + + {% for e in entries %} + + + + + + {% else %} + + {% endfor %} + +
{{ _('Rank') }}{{ _('User') }}{{ _('Score') }}
{{ e.rank }}{{ e.user.username if e.user else (e.get('user', {}) or {}).get('username', e.user_id) }}{{ '%.2f'|format(e.score or 0) }}
{{ _('No rankings yet.') }}
+
+{% endblock %} diff --git a/app/utils/module_registry.py b/app/utils/module_registry.py index ae6a72e36..e2a6e7545 100644 --- a/app/utils/module_registry.py +++ b/app/utils/module_registry.py @@ -811,6 +811,19 @@ def initialize_defaults(cls): ) ) + cls.register( + ModuleDefinition( + id="gamification", + name="Gamification", + description="Badges and leaderboards", + category=ModuleCategory.ADVANCED, + blueprint_name="gamification", + default_enabled=True, + icon="fa-trophy", + order=87, + ) + ) + cls._apply_preset_membership() cls._initialized = True From b29c1823b36bf706e8f7fc21f945feadb032a812 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:49:54 +0200 Subject: [PATCH 08/21] feat(calendar): enable drag-and-drop move and resize for events Wire HTML5 drag/drop and resize handles to the existing move/resize APIs so agenda events can be repositioned without opening the edit form. --- app/static/calendar.css | 43 ++++++ app/static/calendar.js | 226 ++++++++++++++++++++++++++++++- app/templates/calendar/view.html | 2 +- 3 files changed, 266 insertions(+), 5 deletions(-) diff --git a/app/static/calendar.css b/app/static/calendar.css index 21ea19011..727ea8bda 100644 --- a/app/static/calendar.css +++ b/app/static/calendar.css @@ -687,3 +687,46 @@ cursor: pointer; } +/* Drag and drop / resize */ +.event-card[draggable="true"], +.week-event-block[draggable="true"], +.event-badge[draggable="true"] { + cursor: grab; +} + +.event-card.dragging, +.week-event-block.dragging, +.event-badge.dragging { + opacity: 0.45; + cursor: grabbing; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); +} + +.event-card.resizing, +.week-event-block.resizing { + outline: 2px dashed #3b82f6; + z-index: 5; +} + +.drop-target { + background-color: rgba(59, 130, 246, 0.12) !important; + box-shadow: inset 0 0 0 2px rgba(59, 130, 246, 0.45); +} + +.event-resize-handle { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 8px; + cursor: ns-resize; + background: linear-gradient(to bottom, transparent, rgba(59, 130, 246, 0.35)); + border-bottom-left-radius: 0.4rem; + border-bottom-right-radius: 0.4rem; +} + +.event-card, +.week-event-block { + position: relative; +} + diff --git a/app/static/calendar.js b/app/static/calendar.js index 5025f051a..b681f2201 100644 --- a/app/static/calendar.js +++ b/app/static/calendar.js @@ -9,11 +9,16 @@ class Calendar { this.currentDate = new Date(options.currentDate || new Date()); this.container = document.getElementById('calendarContainer'); this.apiUrl = options.apiUrl; + this.moveEventUrl = options.moveEventUrl || (window.calendarData && window.calendarData.moveEventUrl) || ''; + this.resizeEventUrl = options.resizeEventUrl || (window.calendarData && window.calendarData.resizeEventUrl) || ''; + this.csrfToken = options.csrfToken || (window.calendarData && window.calendarData.csrfToken) || ''; this.events = []; this.tasks = []; this.timeEntries = []; this.holidays = []; this.timeOff = []; + this._dragState = null; + this._resizeState = null; // Filters this.showEvents = true; @@ -57,9 +62,218 @@ class Calendar { init() { this.setupEventListeners(); + this.setupDragAndDrop(); this.loadEvents(); this.updateViewLinks(); } + + setupDragAndDrop() { + if (!this.container) return; + + this.container.addEventListener('dragstart', (e) => this.onDragStart(e)); + this.container.addEventListener('dragend', (e) => this.onDragEnd(e)); + this.container.addEventListener('dragover', (e) => this.onDragOver(e)); + this.container.addEventListener('dragleave', (e) => this.onDragLeave(e)); + this.container.addEventListener('drop', (e) => this.onDrop(e)); + this.container.addEventListener('mousedown', (e) => this.onResizeMouseDown(e)); + + document.addEventListener('mousemove', (e) => this.onResizeMouseMove(e)); + document.addEventListener('mouseup', (e) => this.onResizeMouseUp(e)); + } + + onDragStart(e) { + const el = e.target.closest('[data-type="event"][draggable="true"]'); + if (!el || !this.moveEventUrl) return; + const id = el.dataset.id; + const start = el.dataset.start; + const end = el.dataset.end; + if (!id || !start) return; + this._dragState = { + id: parseInt(id, 10), + start, + end, + durationMs: end ? (new Date(end) - new Date(start)) : 60 * 60 * 1000 + }; + el.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(id)); + e.dataTransfer.setData('application/x-calendar-event', JSON.stringify(this._dragState)); + } + + onDragEnd(e) { + const el = e.target.closest('.dragging'); + if (el) el.classList.remove('dragging'); + this.container.querySelectorAll('.drop-target').forEach(n => n.classList.remove('drop-target')); + this._dragState = null; + } + + _dropTargetFromEvent(e) { + const monthCell = e.target.closest('.month-cell[data-date]'); + if (monthCell) return { kind: 'day', date: monthCell.dataset.date, el: monthCell }; + + const weekCol = e.target.closest('.week-day-column[data-date]'); + if (weekCol) { + const blocks = weekCol.querySelector('.week-day-blocks') || weekCol; + const rect = blocks.getBoundingClientRect(); + const y = Math.max(0, Math.min(rect.height, e.clientY - rect.top)); + const minutes = Math.round((y / 60) * 60 / 15) * 15; // 15-min snap within hour px + const hour = Math.floor(y / 60); + const min = Math.round(((y % 60) / 60) * 60 / 15) * 15; + return { kind: 'datetime', date: weekCol.dataset.date, minutes: hour * 60 + min, el: weekCol }; + } + + const dayContainer = e.target.closest('.day-events-container, .events-column'); + if (dayContainer) { + const container = this.container.querySelector('.day-events-container') || dayContainer; + const rect = container.getBoundingClientRect(); + const y = Math.max(0, Math.min(1440, e.clientY - rect.top)); + const minutes = Math.round(y / 15) * 15; + return { + kind: 'datetime', + date: this.formatLocalDate(this.currentDate), + minutes, + el: container + }; + } + return null; + } + + onDragOver(e) { + if (!this._dragState && !(e.dataTransfer && e.dataTransfer.types.includes('application/x-calendar-event'))) { + return; + } + const target = this._dropTargetFromEvent(e); + if (!target) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + this.container.querySelectorAll('.drop-target').forEach(n => { + if (n !== target.el) n.classList.remove('drop-target'); + }); + target.el.classList.add('drop-target'); + } + + onDragLeave(e) { + const el = e.target.closest('.drop-target'); + if (el && !el.contains(e.relatedTarget)) { + el.classList.remove('drop-target'); + } + } + + async onDrop(e) { + e.preventDefault(); + const target = this._dropTargetFromEvent(e); + this.container.querySelectorAll('.drop-target').forEach(n => n.classList.remove('drop-target')); + let state = this._dragState; + if (!state) { + try { + state = JSON.parse(e.dataTransfer.getData('application/x-calendar-event')); + } catch (_) { + return; + } + } + if (!state || !target || !this.moveEventUrl) return; + + let newStart; + if (target.kind === 'day') { + const old = new Date(state.start); + newStart = new Date(`${target.date}T00:00:00`); + newStart.setHours(old.getHours(), old.getMinutes(), 0, 0); + } else { + const h = Math.floor(target.minutes / 60); + const m = target.minutes % 60; + newStart = new Date(`${target.date}T00:00:00`); + newStart.setHours(h, m, 0, 0); + } + const newEnd = new Date(newStart.getTime() + (state.durationMs || 3600000)); + + await this.postMoveOrResize('move', state.id, { + start: newStart.toISOString(), + end: newEnd.toISOString() + }); + this._dragState = null; + this.loadEvents(); + } + + onResizeMouseDown(e) { + const handle = e.target.closest('.event-resize-handle'); + if (!handle || !this.resizeEventUrl) return; + e.preventDefault(); + e.stopPropagation(); + const card = handle.closest('[data-type="event"]'); + if (!card) return; + this._resizeState = { + id: parseInt(card.dataset.id, 10), + start: card.dataset.start, + end: card.dataset.end, + startY: e.clientY, + el: card + }; + card.classList.add('resizing'); + } + + onResizeMouseMove(e) { + if (!this._resizeState) return; + const deltaPx = e.clientY - this._resizeState.startY; + const deltaMin = Math.round(deltaPx / 15) * 15; + const start = new Date(this._resizeState.start); + let end = new Date(this._resizeState.end || start.getTime() + 3600000); + end = new Date(end.getTime() + deltaMin * 60 * 1000); + if (end <= start) end = new Date(start.getTime() + 15 * 60 * 1000); + this._resizeState.previewEnd = end; + const top = parseFloat(this._resizeState.el.style.top) || 0; + const startMin = start.getHours() * 60 + start.getMinutes(); + const height = Math.max(30, (end - start) / 60000); + this._resizeState.el.style.height = `${height}px`; + void top; void startMin; + } + + async onResizeMouseUp() { + if (!this._resizeState) return; + const state = this._resizeState; + this._resizeState = null; + state.el.classList.remove('resizing'); + if (!state.previewEnd) return; + await this.postMoveOrResize('resize', state.id, { end: state.previewEnd.toISOString() }); + this.loadEvents(); + } + + async postMoveOrResize(kind, eventId, body) { + const base = kind === 'move' ? this.moveEventUrl : this.resizeEventUrl; + if (!base) return; + // Template strips /0 from .../events/0/move → .../events/move; restore id before action + const action = kind === 'move' ? 'move' : 'resize'; + let finalUrl = base; + if (base.endsWith(`/${action}`)) { + finalUrl = base.replace(new RegExp(`/${action}$`), `/${eventId}/${action}`); + } else { + finalUrl = `${base.replace(/\/$/, '')}/${eventId}/${action}`; + } + try { + const resp = await fetch(finalUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': this.csrfToken + }, + body: JSON.stringify(body) + }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + console.error('Calendar move/resize failed', data); + alert(data.error || 'Failed to update event'); + } + } catch (err) { + console.error(err); + alert('Failed to update event'); + } + } + + _eventDragAttrs(event) { + if (!event || !event.id || !event.start) return 'data-type="event"'; + const start = event.start; + const end = event.end || ''; + return `draggable="true" data-id="${event.id}" data-type="event" data-start="${start}" data-end="${end}"`; + } setupEventListeners() { // View navigation @@ -530,12 +744,13 @@ class Calendar { const detailId = item.event ? item.event.id : item.entry.id; if (item.type === 'event') { html += ` -
${item.title}
${item.time} +
`; } else if (item.type === 'time_entry') { @@ -717,7 +932,7 @@ class Calendar { const { left, width } = this.columnStyle(item.column, item.totalColumns); const style = `left: ${left}; width: ${width}; top: ${item.topPosition}px; height: ${item.heightMinutes}px;`; if (item.type === 'event') { - html += `
${item.title}
${item.timeStr}
`; + html += `
${item.title}
${item.timeStr}
`; } else if (item.type === 'time_entry') { const durationText = (item.entryEnd != null && item.startTime != null) ? `${item.startTime} - ${item.endTimeStr}` : (item.startTime || item.timeStr || ''); const blockId = (item.entry && item.entry.id) != null ? item.entry.id : item.event.id; @@ -812,7 +1027,7 @@ class Calendar { const eventColor = event.color || (blockType === 'time_entry' ? '#10b981' : '#3b82f6'); const badgeIcon = blockType === 'time_entry' ? '⏱' : '📅'; const badgeClass = blockType === 'time_entry' ? 'event-badge time-entry-badge' : 'event-badge'; - html += `
${badgeIcon} ${eventTitle}
`; + html += `
${badgeIcon} ${eventTitle}
`; } count++; } @@ -1005,7 +1220,10 @@ document.addEventListener('DOMContentLoaded', () => { window.calendar = new Calendar({ viewType: window.calendarData.viewType, currentDate: window.calendarData.currentDate, - apiUrl: window.calendarData.apiUrl + apiUrl: window.calendarData.apiUrl, + moveEventUrl: window.calendarData.moveEventUrl, + resizeEventUrl: window.calendarData.resizeEventUrl, + csrfToken: window.calendarData.csrfToken }); } diff --git a/app/templates/calendar/view.html b/app/templates/calendar/view.html index 13f8ba950..4a1eb102b 100644 --- a/app/templates/calendar/view.html +++ b/app/templates/calendar/view.html @@ -192,6 +192,6 @@ {% endblock %} {% block scripts_extra %} - + {% endblock %} From efb603433ef7eefe415de158687cb8b37644e5e2 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:49:54 +0200 Subject: [PATCH 09/21] feat(projects): add recurring project cost engine Replace the missing costs list template and generate due ProjectCost rows on a schedule, with web CRUD and manual generate-now support. --- app/routes/projects.py | 201 +++++++++++++++++- .../recurring_project_cost_service.py | 73 +++++++ .../projects/add_recurring_cost.html | 97 +++++++++ app/templates/projects/costs.html | 144 +++++++++++++ app/templates/projects/recurring_costs.html | 106 +++++++++ 5 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 app/services/recurring_project_cost_service.py create mode 100644 app/templates/projects/add_recurring_cost.html create mode 100644 app/templates/projects/costs.html create mode 100644 app/templates/projects/recurring_costs.html diff --git a/app/routes/projects.py b/app/routes/projects.py index 008120ba4..e496be75e 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -1,7 +1,7 @@ import csv import io import re -from datetime import datetime +from datetime import datetime, timedelta from decimal import Decimal from flask import ( @@ -28,6 +28,7 @@ Project, ProjectAttachment, ProjectCost, + RecurringProjectCost, Task, TimeEntry, UserFavoriteProject, @@ -1851,6 +1852,204 @@ def api_project_costs(project_id): ) +# ===== RECURRING PROJECT COSTS ROUTES ===== + + +def _parse_recurring_cost_form(project_id): + """Parse and validate recurring cost form fields.""" + description = request.form.get("description", "").strip() + category = request.form.get("category", "").strip() + amount = request.form.get("amount", "").strip() + frequency = request.form.get("frequency", "").strip() + interval = request.form.get("interval", type=int, default=1) + next_run_date_str = request.form.get("next_run_date", "").strip() + end_date_str = request.form.get("end_date", "").strip() + billable = request.form.get("billable") == "on" + currency_code = request.form.get("currency_code", "EUR").strip() + is_active = request.form.get("is_active") == "on" if "is_active" in request.form else True + + if not description or not category or not amount or not frequency or not next_run_date_str: + return None, _("Description, category, amount, frequency, and next run date are required") + + if frequency not in ("daily", "weekly", "monthly", "yearly"): + return None, _("Invalid frequency") + + try: + amount = Decimal(amount) + if amount <= 0: + raise ValueError("Amount must be positive") + except (ValueError, Exception): + return None, _("Invalid amount format") + + try: + next_run_date = datetime.strptime(next_run_date_str, "%Y-%m-%d").date() + except ValueError: + return None, _("Invalid next run date format") + + end_date = None + if end_date_str: + try: + end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() + except ValueError: + return None, _("Invalid end date format") + + return { + "description": description, + "category": category, + "amount": amount, + "frequency": frequency, + "interval": interval or 1, + "next_run_date": next_run_date, + "end_date": end_date, + "billable": billable, + "currency_code": currency_code, + "is_active": is_active, + "project_id": project_id, + "user_id": current_user.id, + }, None + + +@projects_bp.route("/projects//costs/recurring") +@login_required +def list_recurring_costs(project_id): + """List recurring costs for a project.""" + project = Project.query.get_or_404(project_id) + from app.services.recurring_project_cost_service import RecurringProjectCostService + + recurring_costs = RecurringProjectCostService().list_for_project(project_id) + return render_template( + "projects/recurring_costs.html", + project=project, + recurring_costs=recurring_costs, + ) + + +@projects_bp.route("/projects//costs/recurring/add", methods=["GET", "POST"]) +@login_required +def add_recurring_cost(project_id): + """Add a recurring project cost template.""" + project = Project.query.get_or_404(project_id) + + if request.method == "POST": + data, error = _parse_recurring_cost_form(project_id) + if error: + flash(error, "error") + return render_template("projects/add_recurring_cost.html", project=project) + + recurring = RecurringProjectCost(**data) + db.session.add(recurring) + if not safe_commit("add_recurring_project_cost", {"project_id": project_id}): + flash(_("Could not add recurring cost due to a database error. Please check server logs."), "error") + return render_template("projects/add_recurring_cost.html", project=project) + + flash(_("Recurring cost created successfully"), "success") + return redirect(url_for("projects.list_recurring_costs", project_id=project.id)) + + default_next_run_date = (datetime.utcnow() + timedelta(days=1)).strftime("%Y-%m-%d") + return render_template( + "projects/add_recurring_cost.html", + project=project, + default_next_run_date=default_next_run_date, + ) + + +@projects_bp.route("/projects//costs/recurring//edit", methods=["GET", "POST"]) +@login_required +def edit_recurring_cost(project_id, rid): + """Edit a recurring project cost template.""" + project = Project.query.get_or_404(project_id) + recurring = RecurringProjectCost.query.get_or_404(rid) + + if recurring.project_id != project_id: + flash(_("Recurring cost not found"), "error") + return redirect(url_for("projects.list_recurring_costs", project_id=project_id)) + + if not current_user.is_admin and recurring.user_id != current_user.id: + flash(_("You do not have permission to edit this recurring cost"), "error") + return redirect(url_for("projects.list_recurring_costs", project_id=project_id)) + + if request.method == "POST": + data, error = _parse_recurring_cost_form(project_id) + if error: + flash(error, "error") + return render_template("projects/add_recurring_cost.html", project=project, recurring=recurring) + + for field in ( + "description", + "category", + "amount", + "frequency", + "interval", + "next_run_date", + "end_date", + "billable", + "currency_code", + "is_active", + ): + setattr(recurring, field, data[field]) + recurring.updated_at = datetime.utcnow() + + if not safe_commit("edit_recurring_project_cost", {"recurring_id": rid}): + flash(_("Could not update recurring cost due to a database error. Please check server logs."), "error") + return render_template("projects/add_recurring_cost.html", project=project, recurring=recurring) + + flash(_("Recurring cost updated successfully"), "success") + return redirect(url_for("projects.list_recurring_costs", project_id=project.id)) + + return render_template("projects/add_recurring_cost.html", project=project, recurring=recurring) + + +@projects_bp.route("/projects//costs/recurring//delete", methods=["POST"]) +@login_required +def delete_recurring_cost(project_id, rid): + """Delete a recurring project cost template.""" + project = Project.query.get_or_404(project_id) + recurring = RecurringProjectCost.query.get_or_404(rid) + + if recurring.project_id != project_id: + flash(_("Recurring cost not found"), "error") + return redirect(url_for("projects.list_recurring_costs", project_id=project_id)) + + if not current_user.is_admin and recurring.user_id != current_user.id: + flash(_("You do not have permission to delete this recurring cost"), "error") + return redirect(url_for("projects.list_recurring_costs", project_id=project_id)) + + db.session.delete(recurring) + if not safe_commit("delete_recurring_project_cost", {"recurring_id": rid}): + flash(_("Could not delete recurring cost due to a database error. Please check server logs."), "error") + return redirect(url_for("projects.list_recurring_costs", project_id=project_id)) + + flash(_("Recurring cost deleted successfully"), "success") + return redirect(url_for("projects.list_recurring_costs", project_id=project.id)) + + +@projects_bp.route("/projects//costs/recurring//generate-now", methods=["POST"]) +@login_required +def generate_recurring_cost_now(project_id, rid): + """Manually generate a project cost from a recurring template.""" + project = Project.query.get_or_404(project_id) + recurring = RecurringProjectCost.query.get_or_404(rid) + + if recurring.project_id != project_id: + return jsonify({"error": "Recurring cost not found"}), 404 + + if not current_user.is_admin and recurring.user_id != current_user.id: + return jsonify({"error": "Permission denied"}), 403 + + try: + original_next_run_date = recurring.next_run_date + recurring.next_run_date = datetime.utcnow().date() + cost = recurring.generate_cost() + if cost: + db.session.commit() + return jsonify({"success": True, "cost_id": cost.id}) + recurring.next_run_date = original_next_run_date + return jsonify({"error": "Failed to generate cost"}), 400 + except Exception as e: + current_app.logger.error(f"Error generating cost from recurring template: {e}") + return jsonify({"error": str(e)}), 500 + + # ===== PROJECT EXTRA GOODS ROUTES ===== diff --git a/app/services/recurring_project_cost_service.py b/app/services/recurring_project_cost_service.py new file mode 100644 index 000000000..f5da1d033 --- /dev/null +++ b/app/services/recurring_project_cost_service.py @@ -0,0 +1,73 @@ +"""Service for recurring project cost business logic.""" + +import logging +from datetime import datetime +from typing import List, Optional + +from app import db +from app.models import ProjectCost, RecurringProjectCost + +logger = logging.getLogger(__name__) + + +class RecurringProjectCostService: + """Service for recurring project cost operations.""" + + def generate_cost(self, recurring_cost: RecurringProjectCost) -> Optional[ProjectCost]: + if not recurring_cost.should_generate_today(): + return None + + cost_date = datetime.utcnow().date() + cost = ProjectCost( + project_id=recurring_cost.project_id, + user_id=recurring_cost.user_id, + description=recurring_cost.description, + category=recurring_cost.category, + amount=recurring_cost.amount, + cost_date=cost_date, + billable=recurring_cost.billable, + currency_code=recurring_cost.currency_code, + notes=f"Generated from recurring cost #{recurring_cost.id}", + ) + db.session.add(cost) + + recurring_cost.last_generated_at = datetime.utcnow() + recurring_cost.next_run_date = recurring_cost.calculate_next_run_date(cost_date) + return cost + + def generate_due_recurring_costs(self) -> int: + """Generate project costs for all due recurring templates.""" + today = datetime.utcnow().date() + recurring_costs = RecurringProjectCost.query.filter( + RecurringProjectCost.is_active == True, + RecurringProjectCost.next_run_date <= today, + ).all() + + generated = 0 + for recurring in recurring_costs: + try: + if recurring.end_date and today > recurring.end_date: + recurring.is_active = False + db.session.commit() + continue + + cost = self.generate_cost(recurring) + if cost: + db.session.commit() + generated += 1 + logger.info( + "Generated project cost %s from recurring template %s", + cost.id, + recurring.id, + ) + except Exception as exc: + logger.error("Error processing recurring project cost %s: %s", recurring.id, exc) + db.session.rollback() + + return generated + + def list_for_project(self, project_id: int, is_active: Optional[bool] = None) -> List[RecurringProjectCost]: + query = RecurringProjectCost.query.filter_by(project_id=project_id) + if is_active is not None: + query = query.filter(RecurringProjectCost.is_active == is_active) + return query.order_by(RecurringProjectCost.next_run_date.asc()).all() diff --git a/app/templates/projects/add_recurring_cost.html b/app/templates/projects/add_recurring_cost.html new file mode 100644 index 000000000..4a84b9923 --- /dev/null +++ b/app/templates/projects/add_recurring_cost.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} + +{% block title %}{% if recurring %}{{ _('Edit Recurring Cost') }}{% else %}{{ _('Add Recurring Cost') }}{% endif %} · {{ project.name }} - {{ app_name }}{% endblock %} + +{% block content %} +
+
+

+ {% if recurring %}{{ _('Edit Recurring Cost') }}{% else %}{{ _('Add Recurring Cost') }}{% endif %} +

+

{{ project.name }}

+
+ +
+
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ {% if recurring %} +
+ +
+ {% endif %} +
+ +
+ {{ _('Cancel') }} + +
+
+
+
+{% endblock %} diff --git a/app/templates/projects/costs.html b/app/templates/projects/costs.html new file mode 100644 index 000000000..476fe149e --- /dev/null +++ b/app/templates/projects/costs.html @@ -0,0 +1,144 @@ +{% extends "base.html" %} + +{% block title %}{{ _('Project Costs') }} · {{ project.name }} - {{ app_name }}{% endblock %} + +{% block content %} +
+
+

{{ _('Project Costs') }} · {{ project.name }}

+

{{ _('Track direct expenses for this project') }}

+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + {{ _('Clear') }} +
+
+
+ +{% set total_costs = costs|sum(attribute='amount') %} +{% set billable_costs = costs|selectattr('billable')|list|sum(attribute='amount') %} + +
+
+

{{ _('Total Costs') }}

+

{{ costs|length }}

+
+
+

{{ _('Total Amount') }}

+

{{ '%.2f'|format(total_costs) }}

+
+
+

{{ _('Billable Amount') }}

+

{{ '%.2f'|format(billable_costs) }}

+
+
+

{{ _('Categories') }}

+

{{ category_breakdown|length }}

+
+
+ +{% if costs %} +
+ + + + + + + + + + + + + {% for cost in costs %} + + + + + + + + + {% endfor %} + +
{{ _('Date') }}{{ _('Description') }}{{ _('Category') }}{{ _('Amount') }}{{ _('Status') }}{{ _('Actions') }}
{{ cost.cost_date|format_date if cost.cost_date else '' }} +
{{ cost.description }}
+ {% if cost.notes %} +
{{ cost.notes[:80] }}{% if cost.notes|length > 80 %}...{% endif %}
+ {% endif %} +
+ {{ cost.category|capitalize }} + {{ '%.2f'|format(cost.amount) }} {{ cost.currency_code }} + {% if cost.is_invoiced %} + {{ _('Invoiced') }} + {% elif cost.billable %} + {{ _('Billable') }} + {% else %} + {{ _('Non-billable') }} + {% endif %} + +
+ + + + {% if not cost.is_invoiced %} +
+ + +
+ {% endif %} +
+
+
+{% else %} +
+ +

{{ _('No costs found for this project') }}

+ {{ _('Add Your First Cost') }} +
+{% endif %} + +{% if category_breakdown %} +
+

{{ _('Breakdown by Category') }}

+
+ {% for item in category_breakdown %} +
+
{{ item.category|capitalize }}
+
{{ '%.2f'|format(item.total_amount) }}
+
{{ item.count }} {{ _('item(s)') }}
+
+ {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/app/templates/projects/recurring_costs.html b/app/templates/projects/recurring_costs.html new file mode 100644 index 000000000..ab4cdad90 --- /dev/null +++ b/app/templates/projects/recurring_costs.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} + +{% block title %}{{ _('Recurring Costs') }} · {{ project.name }} - {{ app_name }}{% endblock %} + +{% block content %} +
+
+

{{ _('Recurring Costs') }} · {{ project.name }}

+

{{ _('Automate recurring project expenses') }}

+
+ +
+ +
+ + + + + + + + + + + + + + {% for recurring in recurring_costs %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
{{ _('Description') }}{{ _('Category') }}{{ _('Amount') }}{{ _('Frequency') }}{{ _('Next Run') }}{{ _('Status') }}{{ _('Actions') }}
+
{{ recurring.description }}
+ {% if not recurring.billable %} + {{ _('Non-billable') }} + {% endif %} +
{{ recurring.category|capitalize }}{{ '%.2f'|format(recurring.amount) }} {{ recurring.currency_code }} + {{ _('Every') }} {{ recurring.interval }} {{ recurring.frequency }}{% if recurring.interval > 1 %}s{% endif %} + {{ recurring.next_run_date|format_date if recurring.next_run_date else 'N/A' }} + {% if recurring.is_active %} + {{ _('Active') }} + {% else %} + {{ _('Inactive') }} + {% endif %} + +
+ + + + +
+ + +
+
+
+ {{ _('No recurring costs configured.') }} + {{ _('Add one') }} +
+
+ + +{% endblock %} From d91bd44cd22535ed2863cb9a3c211142248fad22 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:49:54 +0200 Subject: [PATCH 10/21] feat(reports): add public tokenized shareable report links Allow saved reports to be shared via expiring optional-password links with a stripped public viewer and revoke controls in the builder UI. --- app/routes/custom_reports.py | 133 ++++++++++++++++++- app/services/shared_report_service.py | 80 ++++++++++++ app/templates/reports/builder.html | 117 +++++++++++++++++ app/templates/reports/saved_views_list.html | 130 +++++++++++++++++++ app/templates/reports/shared_view.html | 135 ++++++++++++++++++++ 5 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 app/services/shared_report_service.py create mode 100644 app/templates/reports/shared_view.html diff --git a/app/routes/custom_reports.py b/app/routes/custom_reports.py index e981b6f06..66be94c17 100644 --- a/app/routes/custom_reports.py +++ b/app/routes/custom_reports.py @@ -5,12 +5,13 @@ import json from datetime import datetime, timedelta -from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for +from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, session, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required from app import db -from app.models import Client, Expense, Invoice, Project, SavedReportView, Task, TimeEntry, User +from app.models import Client, Expense, Invoice, Project, SavedReportView, SharedReportLink, Task, TimeEntry, User +from app.services.shared_report_service import SharedReportService from app.services.unpaid_hours_service import UnpaidHoursService from app.utils.db import safe_commit from app.utils.module_helpers import module_enabled @@ -642,9 +643,15 @@ def list_saved_views(): saved_views = ( SavedReportView.query.filter_by(owner_id=current_user.id).order_by(SavedReportView.created_at.desc()).all() ) + shared_service = SharedReportService() + shared_links_by_view = { + view.id: [link.to_dict() for link in shared_service.list_active_links_for_view(view.id)] + for view in saved_views + } return render_template( "reports/saved_views_list.html", saved_views=saved_views, + shared_links_by_view=shared_links_by_view, convert_app_datetime_to_user=convert_app_datetime_to_user, ) @@ -720,6 +727,128 @@ def delete_saved_view(view_id): return redirect(url_for("custom_reports.list_saved_views")) +def _shared_report_session_key(token: str) -> str: + return f"shared_report_access_{token}" + + +def _has_shared_report_access(link: SharedReportLink) -> bool: + if not link.requires_password: + return True + return session.get(_shared_report_session_key(link.token)) is True + + +@custom_reports_bp.route("/reports/builder//share", methods=["POST"]) +@login_required +@module_enabled("custom_reports") +def create_shared_report_link(view_id): + """Create a share link for the current user (session auth for web UI).""" + saved_view = SavedReportView.query.get_or_404(view_id) + if saved_view.owner_id != current_user.id and not current_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + + data = request.get_json(silent=True) or {} + expiry = (data.get("expiry") or "never").lower() + expires_in_days = None + if expiry == "7d": + expires_in_days = 7 + elif expiry == "30d": + expires_in_days = 30 + elif expiry not in ("never", ""): + return jsonify({"error": "Invalid expiry option"}), 400 + + password = (data.get("password") or "").strip() or None + service = SharedReportService() + link = service.create_link( + saved_view=saved_view, + created_by_id=current_user.id, + expires_in_days=expires_in_days, + password=password, + ) + if not safe_commit("create_shared_report_link", {"view_id": view_id}): + return jsonify({"error": "Failed to create share link"}), 500 + + return jsonify( + { + "success": True, + "message": _("Share link created successfully"), + "link": link.to_dict(include_url=True), + "url": service.get_public_url(link), + } + ), 201 + + +@custom_reports_bp.route("/reports/builder//share/", methods=["DELETE"]) +@login_required +@module_enabled("custom_reports") +def revoke_shared_report_link(view_id, token): + """Revoke a share link for the current user (session auth for web UI).""" + saved_view = SavedReportView.query.get_or_404(view_id) + if saved_view.owner_id != current_user.id and not current_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + + service = SharedReportService() + if not service.revoke_link(view_id, token): + return jsonify({"error": "Share link not found"}), 404 + return jsonify({"success": True, "message": _("Share link revoked successfully")}) + + +@custom_reports_bp.route("/reports/shared/") +@module_enabled("custom_reports") +def view_shared_report(token): + """Public shared report page (no login required).""" + service = SharedReportService() + link, error = service.resolve_token(token) + if error: + return render_template("reports/shared_view.html", token=token, report_name=None, error=error), 404 + + needs_password = link.requires_password and not _has_shared_report_access(link) + return render_template( + "reports/shared_view.html", + token=token, + report_name=link.report_name, + needs_password=needs_password, + error=None, + ) + + +@custom_reports_bp.route("/reports/shared//verify", methods=["POST"]) +@module_enabled("custom_reports") +def verify_shared_report(token): + """Verify password for a protected shared report link.""" + service = SharedReportService() + link, error = service.resolve_token(token) + if error: + return jsonify({"success": False, "message": error}), 404 + + data = request.get_json(silent=True) or {} + password = data.get("password", "") + if not link.check_password(password): + return jsonify({"success": False, "message": _("Invalid password")}), 403 + + session[_shared_report_session_key(token)] = True + return jsonify({"success": True}) + + +@custom_reports_bp.route("/api/reports/shared//data") +@module_enabled("custom_reports") +def shared_report_data(token): + """JSON report data for a shared link (no login required).""" + service = SharedReportService() + link, error = service.resolve_token(token) + if error: + return jsonify({"error": error}), 404 + + if link.requires_password and not _has_shared_report_access(link): + return jsonify({"error": _("Password required")}), 403 + + try: + report_data = service.execute_report_with_snapshot(link) + return jsonify(report_data) + except Exception as exc: + current_app.logger.error("Error executing shared report %s: %s", token, exc, exc_info=True) + return jsonify({"error": str(exc)}), 500 + + def _generate_iterative_reports(saved_view: SavedReportView, config: dict, user_id: int): """ Generate multiple reports, one per custom field value. diff --git a/app/services/shared_report_service.py b/app/services/shared_report_service.py new file mode 100644 index 000000000..3ebe59d73 --- /dev/null +++ b/app/services/shared_report_service.py @@ -0,0 +1,80 @@ +"""Service for public shareable report links.""" + +import json +from datetime import datetime, timedelta +from typing import Any, Dict, Optional, Tuple + +from flask import url_for + +from app import db +from app.models import SavedReportView, SharedReportLink + + +class SharedReportService: + """Create and resolve public shared report links.""" + + def create_link( + self, + saved_view: SavedReportView, + created_by_id: int, + expires_in_days: Optional[int] = None, + password: Optional[str] = None, + ) -> SharedReportLink: + expires_at = None + if expires_in_days is not None: + expires_at = datetime.utcnow() + timedelta(days=expires_in_days) + + try: + config = json.loads(saved_view.config_json) + except (json.JSONDecodeError, TypeError, ValueError): + config = {} + + link = SharedReportLink( + saved_view_id=saved_view.id, + created_by_id=created_by_id, + report_config_snapshot=config, + report_name=saved_view.name, + expires_at=expires_at, + password=password, + ) + db.session.add(link) + db.session.flush() + return link + + def resolve_token(self, token: str) -> Tuple[Optional[SharedReportLink], Optional[str]]: + link = SharedReportLink.query.filter_by(token=token).first() + if not link: + return None, "Link not found" + if not link.is_active: + return None, "Link has been revoked" + if link.is_expired(): + return None, "Link has expired" + return link, None + + def execute_report_with_snapshot(self, link: SharedReportLink) -> Dict[str, Any]: + from app.routes.custom_reports import generate_report_data + + config = link.get_config() + report_data = generate_report_data(config, link.created_by_id) + link.record_access() + db.session.commit() + return report_data + + def get_public_url(self, link: SharedReportLink) -> str: + return url_for("custom_reports.view_shared_report", token=link.token, _external=True) + + def revoke_link(self, saved_view_id: int, token: str) -> bool: + link = SharedReportLink.query.filter_by(saved_view_id=saved_view_id, token=token).first() + if not link: + return False + link.is_active = False + link.updated_at = datetime.utcnow() + db.session.commit() + return True + + def list_active_links_for_view(self, saved_view_id: int): + return ( + SharedReportLink.query.filter_by(saved_view_id=saved_view_id, is_active=True) + .order_by(SharedReportLink.created_at.desc()) + .all() + ) diff --git a/app/templates/reports/builder.html b/app/templates/reports/builder.html index 5c68c5b29..0365a1f1a 100644 --- a/app/templates/reports/builder.html +++ b/app/templates/reports/builder.html @@ -65,6 +65,11 @@

{{ _('Report Canvas') }}

+ {% if saved_view %} + + {% endif %} @@ -172,6 +177,49 @@

{{ _('Report Preview') }}

+{% if saved_view %} + + +{% endif %} + + + + + {% endblock %} diff --git a/app/templates/reports/shared_view.html b/app/templates/reports/shared_view.html new file mode 100644 index 000000000..67bd521af --- /dev/null +++ b/app/templates/reports/shared_view.html @@ -0,0 +1,135 @@ + + + + + + {{ report_name or _('Shared Report') }} - {{ app_name }} + + + + + +
+
+
+
{{ _('Shared Report') }}
+

{{ report_name or _('Report') }}

+
+
{{ app_name }}
+
+
+ +
+ {% if error %} +
{{ error }}
+ {% elif needs_password %} +
+

{{ _('Password Required') }}

+

{{ _('Enter the password to view this report.') }}

+ + + + + +
+ {% else %} +
+ +

{{ _('Loading report...') }}

+
+ + + {% endif %} +
+ + + + From a34c1d2bd344b06a395636783d7739138f319be2 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:49:54 +0200 Subject: [PATCH 11/21] feat(attendance): add geofencing policies for workday clock-in Enforce log/warn/block geofences on workday start/end, expose admin CRUD and mobile GPS capture so field attendance can be location-aware. --- app/routes/api_v1_geofences.py | 15 ++ app/routes/api_v1_workday.py | 87 ++++++++- app/services/geofence_service.py | 182 ++++++++++++++++++ app/services/gps_tracking_service.py | 34 ++-- app/services/workday_session_service.py | 30 +++ app/templates/admin/geofences.html | 128 ++++++++++++ .../android/app/src/main/AndroidManifest.xml | 1 + mobile/ios/Runner/Info.plist | 2 + .../providers/attendance_provider.dart | 56 +++++- .../presentation/widgets/workday_card.dart | 14 +- mobile/lib/utils/location_utils.dart | 25 +++ mobile/pubspec.yaml | 1 + 12 files changed, 540 insertions(+), 35 deletions(-) create mode 100644 app/routes/api_v1_geofences.py create mode 100644 app/services/geofence_service.py create mode 100644 app/templates/admin/geofences.html create mode 100644 mobile/lib/utils/location_utils.dart diff --git a/app/routes/api_v1_geofences.py b/app/routes/api_v1_geofences.py new file mode 100644 index 000000000..af63c723b --- /dev/null +++ b/app/routes/api_v1_geofences.py @@ -0,0 +1,15 @@ +"""REST API v1 endpoints for geofences.""" + +from flask import g + +from app.models.geofence import Geofence +from app.routes.api_v1 import api_v1_bp +from app.utils.api_auth import require_api_token +from app.utils.api_responses import success_response + + +@api_v1_bp.route("/geofences", methods=["GET"]) +@require_api_token("read:time_entries") +def api_list_geofences(): + geofences = Geofence.query.filter_by(is_active=True).order_by(Geofence.name.asc()).all() + return success_response(data={"geofences": [fence.to_dict() for fence in geofences]}) diff --git a/app/routes/api_v1_workday.py b/app/routes/api_v1_workday.py index 83082e32b..9cf848872 100644 --- a/app/routes/api_v1_workday.py +++ b/app/routes/api_v1_workday.py @@ -1,13 +1,43 @@ """REST API v1 endpoints for workday sessions.""" +from typing import Any, Dict, Optional, Tuple + from flask import g, request from app.routes.api_v1 import api_v1_bp +from app.services.geofence_service import GeofenceService from app.services.workday_session_service import WorkdaySessionService from app.utils.api_auth import require_api_token from app.utils.api_responses import error_response, success_response +def _parse_optional_float(value: Any) -> Optional[float]: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _parse_location(data: dict) -> Tuple[Optional[float], Optional[float], Optional[float]]: + latitude = _parse_optional_float(data.get("latitude")) + longitude = _parse_optional_float(data.get("longitude")) + accuracy_m = _parse_optional_float(data.get("accuracy_m")) + return latitude, longitude, accuracy_m + + +def _geofence_payload(enforcement: Dict[str, Any]) -> Dict[str, Any]: + return { + "action": enforcement.get("action"), + "status": enforcement.get("geofence_status"), + "geofence_id": enforcement.get("geofence_id"), + "distance_m": enforcement.get("distance_m"), + "message": enforcement.get("message"), + "violation": enforcement.get("violation"), + } + + @api_v1_bp.route("/workday/status", methods=["GET"]) @require_api_token("read:time_entries") def api_workday_status(): @@ -26,16 +56,37 @@ def api_workday_start(): data = request.get_json(silent=True) or {} notes = (data.get("notes") or "").strip() or None source = (data.get("source") or "mobile").strip() or "mobile" + latitude, longitude, accuracy_m = _parse_location(data) + + enforcement = GeofenceService().enforce_policy(g.api_user, latitude, longitude) + if enforcement.get("action") == "block": + return error_response( + message=enforcement.get("message") or "Clock-in blocked by geofence policy", + status_code=403, + error_code="geofence_blocked", + details=_geofence_payload(enforcement), + ) - result = WorkdaySessionService().start_workday(g.api_user.id, notes=notes, source=source) + result = WorkdaySessionService().start_workday( + g.api_user.id, + notes=notes, + source=source, + latitude=latitude, + longitude=longitude, + accuracy_m=accuracy_m, + geofence_enforcement=enforcement, + ) if not result["success"]: return error_response( message=result.get("message", "Could not start workday"), status_code=400, error_code=result.get("error"), ) + response_data = {"session": result["session"].to_dict()} + if enforcement.get("action") == "warn": + response_data["geofence"] = _geofence_payload(enforcement) return success_response( - data={"session": result["session"].to_dict()}, + data=response_data, message=result["message"], ) @@ -58,15 +109,43 @@ def api_workday_end(): error_code="invalid_end_time", ) - result = WorkdaySessionService().end_workday(g.api_user.id, notes=notes, at_time=at_time) + latitude, longitude, accuracy_m = _parse_location(data) + active_session = WorkdaySessionService().get_active_session(g.api_user.id) + + enforcement = GeofenceService().enforce_policy( + g.api_user, + latitude, + longitude, + session=active_session, + ) + if enforcement.get("action") == "block": + return error_response( + message=enforcement.get("message") or "Clock-out blocked by geofence policy", + status_code=403, + error_code="geofence_blocked", + details=_geofence_payload(enforcement), + ) + + result = WorkdaySessionService().end_workday( + g.api_user.id, + notes=notes, + at_time=at_time, + latitude=latitude, + longitude=longitude, + accuracy_m=accuracy_m, + geofence_enforcement=enforcement, + ) if not result["success"]: return error_response( message=result.get("message", "Could not end workday"), status_code=400, error_code=result.get("error"), ) + response_data = {"session": result["session"].to_dict()} + if enforcement.get("action") == "warn": + response_data["geofence"] = _geofence_payload(enforcement) return success_response( - data={"session": result["session"].to_dict()}, + data=response_data, message=result["message"], ) diff --git a/app/services/geofence_service.py b/app/services/geofence_service.py new file mode 100644 index 000000000..c919e1be8 --- /dev/null +++ b/app/services/geofence_service.py @@ -0,0 +1,182 @@ +"""Geofence enforcement for workday clock-in/out.""" + +from typing import Any, Dict, List, Optional + +from app.models.geofence import Geofence, GeofencePolicy +from app.models.workday_session import WorkdaySession +from app.services.gps_tracking_service import haversine_distance_m + +_POLICY_RANK = { + GeofencePolicy.LOG: 0, + GeofencePolicy.WARN: 1, + GeofencePolicy.BLOCK: 2, +} + + +class GeofenceService: + """Check coordinates against configured geofences and enforce attendance policies.""" + + def _applicable_geofences(self, session: Optional[WorkdaySession] = None) -> List[Geofence]: + project_id = getattr(session, "project_id", None) if session else None + return Geofence.active_query(project_id=project_id).all() + + def check_point( + self, + lat: float, + lng: float, + session: Optional[WorkdaySession] = None, + ) -> Dict[str, Any]: + """Return geofence match details for a coordinate pair.""" + geofences = self._applicable_geofences(session) + if not geofences: + return { + "inside": True, + "geofence_status": "no_geofences", + "matches": [], + "nearest": None, + } + + matches = [] + nearest = None + nearest_distance = None + + for geofence in geofences: + distance_m = haversine_distance_m(lat, lng, geofence.lat, geofence.lng) + inside = distance_m <= float(geofence.radius_m) + entry = { + "geofence": geofence, + "geofence_id": geofence.id, + "name": geofence.name, + "distance_m": round(distance_m, 1), + "radius_m": geofence.radius_m, + "inside": inside, + "policy": geofence.policy, + } + matches.append(entry) + if nearest_distance is None or distance_m < nearest_distance: + nearest_distance = distance_m + nearest = entry + if inside: + return { + "inside": True, + "geofence_status": "inside", + "matches": matches, + "nearest": entry, + "geofence_id": geofence.id, + "geofence": geofence, + "distance_m": round(distance_m, 1), + } + + return { + "inside": False, + "geofence_status": "outside", + "matches": matches, + "nearest": nearest, + "geofence_id": None, + "distance_m": round(nearest_distance, 1) if nearest_distance is not None else None, + } + + def _strictest_policy(self, geofences: List[Geofence]) -> str: + policy = GeofencePolicy.LOG + for geofence in geofences: + if _POLICY_RANK.get(geofence.policy, 0) > _POLICY_RANK.get(policy, 0): + policy = geofence.policy + return policy + + def _policy_to_action(self, policy: str) -> str: + if policy == GeofencePolicy.BLOCK: + return "block" + if policy == GeofencePolicy.WARN: + return "warn" + return "allow" + + def _build_violation_message(self, check: Dict[str, Any]) -> str: + nearest = check.get("nearest") or {} + name = nearest.get("name") or "work area" + distance_m = check.get("distance_m") + if distance_m is not None: + return f"You are {distance_m:.0f} m outside {name}." + return f"You are outside the approved work area ({name})." + + def enforce_policy( + self, + user, + lat: Optional[float], + lng: Optional[float], + session: Optional[WorkdaySession] = None, + ) -> Dict[str, Any]: + """Evaluate attendance policy for clock-in/out coordinates.""" + _ = user # reserved for future per-user/project rules + geofences = self._applicable_geofences(session) + + if not geofences: + return { + "action": "allow", + "geofence_status": "no_geofences", + "geofence_id": None, + "distance_m": None, + "message": None, + "violation": None, + } + + if lat is None or lng is None: + policy = self._strictest_policy(geofences) + action = self._policy_to_action(policy) + message = "Location is required for geofenced attendance." + result = { + "action": action, + "geofence_status": "unknown", + "geofence_id": None, + "distance_m": None, + "message": message, + "violation": {"reason": "missing_location", "policy": policy}, + } + if action == "allow": + result["message"] = None + return result + + check = self.check_point(lat, lng, session=session) + if check["inside"]: + geofence = check.get("geofence") + return { + "action": "allow", + "geofence_status": "inside", + "geofence_id": check.get("geofence_id"), + "distance_m": check.get("distance_m"), + "message": None, + "violation": None, + "geofence_name": geofence.name if geofence else None, + } + + policy = self._strictest_policy(geofences) + action = self._policy_to_action(policy) + message = self._build_violation_message(check) + return { + "action": action, + "geofence_status": "outside", + "geofence_id": None, + "distance_m": check.get("distance_m"), + "message": message, + "violation": { + "policy": policy, + "nearest_geofence_id": (check.get("nearest") or {}).get("geofence_id"), + "nearest_geofence_name": (check.get("nearest") or {}).get("name"), + "distance_m": check.get("distance_m"), + }, + "nearest_geofence": check.get("nearest"), + } + + def apply_to_session( + self, + session: WorkdaySession, + lat: Optional[float], + lng: Optional[float], + accuracy_m: Optional[float], + enforcement: Dict[str, Any], + ) -> None: + """Persist geofence evaluation on a workday session.""" + session.latitude = lat + session.longitude = lng + session.accuracy_m = accuracy_m + session.geofence_id = enforcement.get("geofence_id") + session.geofence_status = enforcement.get("geofence_status") diff --git a/app/services/gps_tracking_service.py b/app/services/gps_tracking_service.py index b0a93f92d..ee2878d6d 100644 --- a/app/services/gps_tracking_service.py +++ b/app/services/gps_tracking_service.py @@ -13,6 +13,22 @@ logger = logging.getLogger(__name__) +def haversine_distance_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + """Great-circle distance between two WGS84 points in meters.""" + from math import atan2, cos, radians, sin, sqrt + + earth_radius_m = 6371000.0 + lat1_r = radians(lat1) + lon1_r = radians(lng1) + lat2_r = radians(lat2) + lon2_r = radians(lng2) + dlat = lat2_r - lat1_r + dlon = lon2_r - lon1_r + a = sin(dlat / 2) ** 2 + cos(lat1_r) * cos(lat2_r) * sin(dlon / 2) ** 2 + c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return earth_radius_m * c + + class GPSTrackingService: """Service for GPS tracking and mileage calculation""" @@ -125,23 +141,7 @@ def calculate_route_distance( """Calculate route distance between two points (can use routing API)""" # Simple Haversine calculation (straight line) # In production, use Google Maps API or similar for actual route distance - - from math import atan2, cos, radians, sin, sqrt - - R = 6371 # Earth radius in km - - lat1 = radians(start_lat) - lon1 = radians(start_lng) - lat2 = radians(end_lat) - lon2 = radians(end_lng) - - dlat = lat2 - lat1 - dlon = lon2 - lon1 - - a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 - c = 2 * atan2(sqrt(a), sqrt(1 - a)) - - distance_km = R * c + distance_km = haversine_distance_m(start_lat, start_lng, end_lat, end_lng) / 1000.0 distance_miles = distance_km * 0.621371 return { diff --git a/app/services/workday_session_service.py b/app/services/workday_session_service.py index b1b018db9..bcd10a21e 100644 --- a/app/services/workday_session_service.py +++ b/app/services/workday_session_service.py @@ -76,6 +76,10 @@ def start_workday( user_id: int, notes: Optional[str] = None, source: str = "manual", + latitude: Optional[float] = None, + longitude: Optional[float] = None, + accuracy_m: Optional[float] = None, + geofence_enforcement: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: result = self.compliance.clock_in(user_id, notes=notes, source=source) if not result.get("success"): @@ -92,6 +96,17 @@ def start_workday( db.session.flush() period.workday_session_id = session.id + if geofence_enforcement is not None: + from app.services.geofence_service import GeofenceService + + GeofenceService().apply_to_session( + session, + latitude, + longitude, + accuracy_m, + geofence_enforcement, + ) + if not safe_commit("start_workday", {"user_id": user_id}): return { "success": False, @@ -106,6 +121,10 @@ def end_workday( user_id: int, notes: Optional[str] = None, at_time: Optional[datetime] = None, + latitude: Optional[float] = None, + longitude: Optional[float] = None, + accuracy_m: Optional[float] = None, + geofence_enforcement: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: session = self.get_active_session(user_id) if not session: @@ -138,6 +157,17 @@ def end_workday( session.notes = (session.notes or "") + ("\n" if session.notes else "") + notes.strip() session.calculate_duration() + if geofence_enforcement is not None: + from app.services.geofence_service import GeofenceService + + GeofenceService().apply_to_session( + session, + latitude, + longitude, + accuracy_m, + geofence_enforcement, + ) + if not safe_commit("end_workday", {"user_id": user_id, "session_id": session.id}): return { "success": False, diff --git a/app/templates/admin/geofences.html b/app/templates/admin/geofences.html new file mode 100644 index 000000000..f0bc4784b --- /dev/null +++ b/app/templates/admin/geofences.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block content %} +{% set breadcrumbs = [ + {'text': _('Admin'), 'url': url_for('admin.dashboard')}, + {'text': _('Geofences')} +] %} +{{ page_header(icon_class='fas fa-map-marker-alt', title_text=_('Geofences'), subtitle_text=_('Field geofencing and attendance policies'), breadcrumbs=breadcrumbs) }} + +
+
+

{{ _('Configured Geofences') }}

+
+ {% for g in geofences %} + {% set is_editing = editing_geofence is defined and editing_geofence and editing_geofence.id == g.id %} +
+
+
+
+ {{ g.name }} + {% if not g.is_active %}({{ _('inactive') }}){% endif %} +
+
+ {{ g.lat }}, {{ g.lng }} · {{ g.radius_m }} m · {{ g.policy }} + {% if g.project %} · {{ g.project.name }}{% endif %} +
+ {% if g.address %} +
{{ g.address }}
+ {% endif %} +
+
+ {{ _('Edit') }} +
+ + +
+
+
+ {% if is_editing %} +
+ + + +
+ + + +
+
+ + + + + {{ _('Cancel') }} +
+
+ {% endif %} +
+ {% else %} +

{{ _('No geofences configured yet.') }}

+ {% endfor %} +
+
+ +
+

{{ _('Create Geofence') }}

+

+ {{ _('Policies: log records violations, warn allows clock-in/out with a warning, block prevents the action.') }} +

+
+ +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+{% endblock %} diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 88494c4e6..c82ab0847 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + NSUserTrackingUsageDescription This app does not track users across apps or websites + NSLocationWhenInUseUsageDescription + TimeTracker uses your location to verify geofenced workday clock-in and clock-out. BGTaskSchedulerPermittedIdentifiers com.pravera.flutter_foreground_task.refresh diff --git a/mobile/lib/presentation/providers/attendance_provider.dart b/mobile/lib/presentation/providers/attendance_provider.dart index 1375c0669..d13e5de60 100644 --- a/mobile/lib/presentation/providers/attendance_provider.dart +++ b/mobile/lib/presentation/providers/attendance_provider.dart @@ -1,5 +1,7 @@ +import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:timetracker_mobile/presentation/providers/api_provider.dart'; +import 'package:timetracker_mobile/utils/location_utils.dart'; class AttendanceState { const AttendanceState({ @@ -7,6 +9,7 @@ class AttendanceState { this.workActive = false, this.breakActive = false, this.error, + this.warning, this.workPeriod, this.today, }); @@ -15,6 +18,7 @@ class AttendanceState { final bool workActive; final bool breakActive; final String? error; + final String? warning; final Map? workPeriod; final Map? today; @@ -23,6 +27,7 @@ class AttendanceState { bool? workActive, bool? breakActive, String? error, + String? warning, Map? workPeriod, Map? today, }) { @@ -31,6 +36,7 @@ class AttendanceState { workActive: workActive ?? this.workActive, breakActive: breakActive ?? this.breakActive, error: error, + warning: warning, workPeriod: workPeriod ?? this.workPeriod, today: today ?? this.today, ); @@ -66,34 +72,72 @@ class AttendanceNotifier extends StateNotifier { } } + String? _extractGeofenceWarning(Map data) { + final geofence = data['geofence']; + if (geofence is Map && geofence['message'] != null) { + return geofence['message'].toString(); + } + return null; + } + + String _formatWorkdayError(Object error) { + if (error is DioException) { + final data = error.response?.data; + if (data is Map) { + final message = data['message'] ?? data['error']; + if (message != null) return message.toString(); + } + if (error.message != null && error.message!.isNotEmpty) { + return error.message!; + } + } + return error.toString(); + } + Future startWorkday() async { + state = state.copyWith(loading: true, error: null, warning: null); try { final client = await ref.read(apiClientProvider.future); if (client == null) { - state = state.copyWith(error: 'Not authenticated'); + state = state.copyWith(loading: false, error: 'Not authenticated'); return false; } - await client.startWorkday(); + final position = await getWorkdayPosition(); + final result = await client.startWorkday( + latitude: position?.latitude, + longitude: position?.longitude, + accuracyM: position?.accuracy, + ); + final warning = _extractGeofenceWarning(result); await refresh(); + state = state.copyWith(loading: false, warning: warning); return true; } catch (e) { - state = state.copyWith(error: e.toString()); + state = state.copyWith(loading: false, error: _formatWorkdayError(e)); return false; } } Future endWorkday() async { + state = state.copyWith(loading: true, error: null, warning: null); try { final client = await ref.read(apiClientProvider.future); if (client == null) { - state = state.copyWith(error: 'Not authenticated'); + state = state.copyWith(loading: false, error: 'Not authenticated'); return false; } - await client.endWorkday(); + final position = await getWorkdayPosition(); + final result = await client.endWorkday( + latitude: position?.latitude, + longitude: position?.longitude, + accuracyM: position?.accuracy, + ); + final warning = _extractGeofenceWarning(result); await refresh(); + state = state.copyWith(loading: false, warning: warning); return true; } catch (e) { - state = state.copyWith(error: e.toString()); + state = state.copyWith(loading: false, error: _formatWorkdayError(e)); return false; } } diff --git a/mobile/lib/presentation/widgets/workday_card.dart b/mobile/lib/presentation/widgets/workday_card.dart index c60661545..5601fc28d 100644 --- a/mobile/lib/presentation/widgets/workday_card.dart +++ b/mobile/lib/presentation/widgets/workday_card.dart @@ -12,14 +12,7 @@ class WorkdayCard extends ConsumerWidget { WidgetRef ref, Future Function() action, ) async { - final ok = await action(); - if (!context.mounted) return; - final error = ref.read(attendanceProvider).error; - if (!ok && error != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error)), - ); - } + await action(); } Future _showHistory(BuildContext context, WidgetRef ref) async { @@ -227,6 +220,11 @@ class WorkdayCard extends ConsumerWidget { SnackBar(content: Text(next.error!)), ); } + if (next.warning != null && next.warning != prev?.warning) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(next.warning!)), + ); + } }); final attendanceState = ref.watch(attendanceProvider); diff --git a/mobile/lib/utils/location_utils.dart b/mobile/lib/utils/location_utils.dart new file mode 100644 index 000000000..db0f16a94 --- /dev/null +++ b/mobile/lib/utils/location_utils.dart @@ -0,0 +1,25 @@ +import 'package:geolocator/geolocator.dart'; + +/// Request permission and return the current device position when available. +Future getWorkdayPosition() async { + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + return null; + } + + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return null; + } + + return Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 15), + ), + ); +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1b0897220..c09012a25 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: package_info_plus: ^8.0.0 google_fonts: ^8.0.0 share_plus: ^10.0.0 + geolocator: ^13.0.2 # OpenTelemetry OTLP/HTTP traces (same env names as server: OTEL_EXPORTER_OTLP_* via --dart-define) opentelemetry: ^0.18.11 From faeac46e879483bb1826b170839381898dec76ff Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:50:12 +0200 Subject: [PATCH 12/21] feat(integrations): add ActivityWatch rules/inbox and sync-error routes Expose rule CRUD, review inbox actions, bucket discovery, and dead-letter retry endpoints used by the new integration templates. --- app/routes/integrations.py | 194 +++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/app/routes/integrations.py b/app/routes/integrations.py index c2e931350..c91a80015 100644 --- a/app/routes/integrations.py +++ b/app/routes/integrations.py @@ -1677,3 +1677,197 @@ def test_connection_wizard(provider): except Exception as e: logger.error(f"Connection test error for {provider}: {e}", exc_info=True) return jsonify({"success": False, "error": str(e)}), 500 + + +@integrations_bp.route("/integrations/sync-errors") +@login_required +def sync_errors(): + """List unresolved integration sync dead-letter errors.""" + from app.models.integration_sync_error import IntegrationSyncError + + if not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("integrations.list_integrations")) + + errors = ( + IntegrationSyncError.query.filter_by(resolved=False) + .order_by(IntegrationSyncError.created_at.desc()) + .limit(200) + .all() + ) + return render_template("integrations/sync_errors.html", errors=errors) + + +@integrations_bp.route("/integrations/sync-errors//resolve", methods=["POST"]) +@login_required +def resolve_sync_error(error_id): + from app.models.integration_sync_error import IntegrationSyncError + from app.utils.db import safe_commit + + if not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("integrations.list_integrations")) + + row = IntegrationSyncError.query.get_or_404(error_id) + row.resolved = True + safe_commit("resolve_sync_error", {"error_id": error_id}) + flash(_("Error marked resolved"), "success") + return redirect(url_for("integrations.sync_errors")) + + +@integrations_bp.route("/integrations/sync-errors//retry", methods=["POST"]) +@login_required +def retry_sync_error(error_id): + from datetime import datetime + + from app.models.integration_sync_error import IntegrationSyncError + from app.services.integration_service import IntegrationService + from app.utils.db import safe_commit + + if not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("integrations.list_integrations")) + + row = IntegrationSyncError.query.get_or_404(error_id) + row.retry_count = (row.retry_count or 0) + 1 + row.last_retry_at = datetime.utcnow() + try: + connector = IntegrationService.get_connector_instance(row.connector, user_id=None) + if connector: + connector.sync_data(sync_type="incremental") + row.resolved = True + flash(_("Retry triggered"), "success") + else: + flash(_("Connector unavailable"), "error") + except Exception as e: + flash(_("Retry failed: %(err)s", err=str(e)), "error") + safe_commit("retry_sync_error", {"error_id": error_id}) + return redirect(url_for("integrations.sync_errors")) + + +@integrations_bp.route("/integrations/activitywatch/rules", methods=["GET", "POST"]) +@login_required +def activitywatch_rules(): + from app.models import Project + from app.models.activitywatch_rule import ActivityWatchRule + from app.utils.db import safe_commit + + if request.method == "POST": + rule = ActivityWatchRule( + user_id=current_user.id, + name=(request.form.get("name") or "Rule").strip(), + pattern_type=(request.form.get("pattern_type") or "app").strip(), + pattern_value=(request.form.get("pattern_value") or "").strip(), + project_id=request.form.get("project_id", type=int) or None, + task_id=request.form.get("task_id", type=int) or None, + billable="billable" in request.form, + min_duration_seconds=request.form.get("min_duration_seconds", type=int), + priority=request.form.get("priority", type=int) or 100, + is_active=True, + ) + if not rule.pattern_value: + flash(_("Pattern is required"), "error") + else: + db.session.add(rule) + if safe_commit("create_aw_rule"): + flash(_("Rule created"), "success") + else: + flash(_("Could not create rule"), "error") + return redirect(url_for("integrations.activitywatch_rules")) + + rules = ActivityWatchRule.query.filter_by(user_id=current_user.id).order_by(ActivityWatchRule.priority.asc()).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + return render_template("integrations/activitywatch_rules.html", rules=rules, projects=projects) + + +@integrations_bp.route("/integrations/activitywatch/rules//delete", methods=["POST"]) +@login_required +def activitywatch_rule_delete(rule_id): + from app.models.activitywatch_rule import ActivityWatchRule + from app.utils.db import safe_commit + + rule = ActivityWatchRule.query.get_or_404(rule_id) + if rule.user_id != current_user.id and not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("integrations.activitywatch_rules")) + db.session.delete(rule) + safe_commit("delete_aw_rule", {"rule_id": rule_id}) + flash(_("Rule deleted"), "success") + return redirect(url_for("integrations.activitywatch_rules")) + + +@integrations_bp.route("/integrations/activitywatch/inbox", methods=["GET"]) +@login_required +def activitywatch_inbox(): + from app.models.activitywatch_rule import PendingActivity + + items = ( + PendingActivity.query.filter_by(user_id=current_user.id, status="pending") + .order_by(PendingActivity.started_at.desc()) + .limit(200) + .all() + ) + return render_template("integrations/activitywatch_inbox.html", items=items) + + +@integrations_bp.route("/integrations/activitywatch/inbox//approve", methods=["POST"]) +@login_required +def activitywatch_inbox_approve(item_id): + from datetime import timedelta + + from app.models import TimeEntry + from app.models.activitywatch_rule import PendingActivity + from app.utils.db import safe_commit + + item = PendingActivity.query.get_or_404(item_id) + if item.user_id != current_user.id and not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("integrations.activitywatch_inbox")) + end_time = item.started_at + timedelta(seconds=item.duration_seconds or 0) + entry = TimeEntry( + user_id=item.user_id, + project_id=item.suggested_project_id, + task_id=item.suggested_task_id, + start_time=item.started_at, + end_time=end_time, + notes=item.notes, + source="auto", + billable=bool(item.suggested_billable), + ) + db.session.add(entry) + item.status = "approved" + if safe_commit("approve_pending_activity", {"item_id": item_id}): + flash(_("Activity approved"), "success") + else: + flash(_("Could not approve"), "error") + return redirect(url_for("integrations.activitywatch_inbox")) + + +@integrations_bp.route("/integrations/activitywatch/inbox//discard", methods=["POST"]) +@login_required +def activitywatch_inbox_discard(item_id): + from app.models.activitywatch_rule import PendingActivity + from app.utils.db import safe_commit + + item = PendingActivity.query.get_or_404(item_id) + if item.user_id != current_user.id and not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("integrations.activitywatch_inbox")) + item.status = "discarded" + safe_commit("discard_pending_activity", {"item_id": item_id}) + flash(_("Activity discarded"), "success") + return redirect(url_for("integrations.activitywatch_inbox")) + + +@integrations_bp.route("/api/integrations/activitywatch/buckets", methods=["GET"]) +@login_required +def activitywatch_buckets_api(): + from app.services.integration_service import IntegrationService + + try: + connector = IntegrationService.get_connector_instance("activitywatch", user_id=current_user.id) + if not connector: + return jsonify({"buckets": [], "error": "not configured"}), 400 + return jsonify({"buckets": connector.list_buckets()}) + except Exception as e: + return jsonify({"buckets": [], "error": str(e)}), 400 From c82ab828e5dfb53809b44c14787a0d98dedb44e7 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:50:12 +0200 Subject: [PATCH 13/21] feat(admin): add payroll template and geofence management pages Provide admin CRUD for payroll export templates and attendance geofences so operators can configure the new workforce and field policies. --- app/routes/admin.py | 253 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) diff --git a/app/routes/admin.py b/app/routes/admin.py index caf3dcc8d..d6e16fd22 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -5926,3 +5926,256 @@ def list_integrations_admin(): def integration_setup(provider): """Setup page for configuring integration OAuth credentials. Redirect to main integrations manage page.""" return redirect(url_for("integrations.manage_integration", provider=provider)) + + +# ==================== Payroll Export Templates ==================== + + +@admin_bp.route("/admin/payroll-templates", methods=["GET", "POST"]) +@login_required +@admin_or_permission_required("access_admin") +def payroll_templates(): + from app.models.payroll_export_template import PayrollExportTemplate + + PayrollExportTemplate.ensure_builtin_defaults() + + if request.method == "POST": + name = (request.form.get("name") or "").strip() + grouping = (request.form.get("grouping") or "week").strip() + fmt = (request.form.get("format") or "csv").strip() + delimiter = (request.form.get("delimiter") or ",").strip() or "," + columns_raw = (request.form.get("columns") or "").strip() + columns = [c.strip() for c in columns_raw.split(",") if c.strip()] + if not columns: + columns = list(PayrollExportTemplate.DEFAULT_COLUMNS) + is_default = "is_default" in request.form + + if not name: + flash(_("Name is required"), "error") + return redirect(url_for("admin.payroll_templates")) + + if is_default: + for t in PayrollExportTemplate.query.filter_by(is_default=True).all(): + t.is_default = False + + tmpl = PayrollExportTemplate( + name=name, + columns=columns, + grouping=grouping if grouping in ("week", "day", "project") else "week", + format=fmt if fmt in ("csv", "xlsx") else "csv", + delimiter=delimiter, + is_default=is_default, + is_builtin=False, + ) + db.session.add(tmpl) + if safe_commit("create_payroll_template"): + flash(_("Payroll template created"), "success") + else: + flash(_("Could not create template"), "error") + return redirect(url_for("admin.payroll_templates")) + + templates = PayrollExportTemplate.query.order_by(PayrollExportTemplate.name.asc()).all() + return render_template( + "admin/payroll_templates.html", + templates=templates, + default_columns=PayrollExportTemplate.DEFAULT_COLUMNS, + ) + + +@admin_bp.route("/admin/payroll-templates//edit", methods=["POST"]) +@login_required +@admin_or_permission_required("access_admin") +def edit_payroll_template(template_id): + from app.models.payroll_export_template import PayrollExportTemplate + + tmpl = PayrollExportTemplate.query.get_or_404(template_id) + if not tmpl.is_builtin: + name = (request.form.get("name") or "").strip() + if name: + tmpl.name = name + grouping = (request.form.get("grouping") or tmpl.grouping).strip() + tmpl.grouping = grouping if grouping in ("week", "day", "project") else tmpl.grouping + fmt = (request.form.get("format") or tmpl.format).strip() + tmpl.format = fmt if fmt in ("csv", "xlsx") else tmpl.format + delimiter = (request.form.get("delimiter") or tmpl.delimiter).strip() or "," + tmpl.delimiter = delimiter + columns_raw = (request.form.get("columns") or "").strip() + if columns_raw: + tmpl.columns = [c.strip() for c in columns_raw.split(",") if c.strip()] + if "is_default" in request.form: + for t in PayrollExportTemplate.query.filter_by(is_default=True).all(): + t.is_default = False + tmpl.is_default = True + if safe_commit("edit_payroll_template", {"template_id": template_id}): + flash(_("Template updated"), "success") + else: + flash(_("Could not update template"), "error") + return redirect(url_for("admin.payroll_templates")) + + +@admin_bp.route("/admin/payroll-templates//delete", methods=["POST"]) +@login_required +@admin_or_permission_required("access_admin") +def delete_payroll_template(template_id): + from app.models.payroll_export_template import PayrollExportTemplate + + tmpl = PayrollExportTemplate.query.get_or_404(template_id) + if tmpl.is_builtin: + flash(_("Cannot delete built-in templates"), "error") + return redirect(url_for("admin.payroll_templates")) + was_default = tmpl.is_default + db.session.delete(tmpl) + if safe_commit("delete_payroll_template", {"template_id": template_id}): + if was_default: + PayrollExportTemplate.ensure_builtin_defaults() + builtin = PayrollExportTemplate.query.filter_by(name="Generic Payroll").first() + if builtin: + builtin.is_default = True + safe_commit("reset_default_payroll_template") + flash(_("Template deleted"), "success") + else: + flash(_("Could not delete template"), "error") + return redirect(url_for("admin.payroll_templates")) + + +# ==================== Geofences ==================== + + +@admin_bp.route("/admin/geofences", methods=["GET", "POST"]) +@login_required +@admin_or_permission_required("access_admin") +def geofences(): + from app.models.geofence import Geofence, GeofencePolicy + from app.models import Project + + if request.method == "POST": + name = (request.form.get("name") or "").strip() + lat = request.form.get("lat") + lng = request.form.get("lng") + radius_m = request.form.get("radius_m") or "100" + address = (request.form.get("address") or "").strip() or None + project_raw = (request.form.get("project_id") or "").strip() + policy = (request.form.get("policy") or GeofencePolicy.LOG).strip() + is_active = "is_active" in request.form + + if not name: + flash(_("Name is required"), "error") + return redirect(url_for("admin.geofences")) + + try: + lat_f = float(lat) + lng_f = float(lng) + radius_f = float(radius_m) + except (TypeError, ValueError): + flash(_("Valid latitude, longitude, and radius are required"), "error") + return redirect(url_for("admin.geofences")) + + if policy not in GeofencePolicy.CHOICES: + policy = GeofencePolicy.LOG + + project_id = int(project_raw) if project_raw.isdigit() else None + + geofence = Geofence( + name=name, + lat=lat_f, + lng=lng_f, + radius_m=radius_f, + address=address, + project_id=project_id, + policy=policy, + is_active=is_active, + ) + db.session.add(geofence) + if safe_commit("create_geofence"): + flash(_("Geofence created"), "success") + else: + flash(_("Could not create geofence"), "error") + return redirect(url_for("admin.geofences")) + + geofence_list = Geofence.query.order_by(Geofence.name.asc()).all() + projects = Project.query.filter_by(status="active").order_by(Project.name.asc()).all() + return render_template( + "admin/geofences.html", + geofences=geofence_list, + projects=projects, + policies=GeofencePolicy.CHOICES, + ) + + +@admin_bp.route("/admin/geofences//edit", methods=["GET", "POST"]) +@login_required +@admin_or_permission_required("access_admin") +def edit_geofence(geofence_id): + from app.models.geofence import Geofence, GeofencePolicy + from app.models import Project + + geofence = Geofence.query.get_or_404(geofence_id) + projects = Project.query.filter_by(status="active").order_by(Project.name.asc()).all() + + if request.method == "POST": + name = (request.form.get("name") or "").strip() + lat = request.form.get("lat") + lng = request.form.get("lng") + radius_m = request.form.get("radius_m") + address = (request.form.get("address") or "").strip() or None + project_raw = (request.form.get("project_id") or "").strip() + policy = (request.form.get("policy") or geofence.policy).strip() + is_active = "is_active" in request.form + + if not name: + flash(_("Name is required"), "error") + return render_template( + "admin/geofences.html", + geofences=Geofence.query.order_by(Geofence.name.asc()).all(), + projects=projects, + policies=GeofencePolicy.CHOICES, + editing_geofence=geofence, + ) + + try: + geofence.lat = float(lat) + geofence.lng = float(lng) + geofence.radius_m = float(radius_m) + except (TypeError, ValueError): + flash(_("Valid latitude, longitude, and radius are required"), "error") + return render_template( + "admin/geofences.html", + geofences=Geofence.query.order_by(Geofence.name.asc()).all(), + projects=projects, + policies=GeofencePolicy.CHOICES, + editing_geofence=geofence, + ) + + geofence.name = name + geofence.address = address + geofence.project_id = int(project_raw) if project_raw.isdigit() else None + geofence.policy = policy if policy in GeofencePolicy.CHOICES else geofence.policy + geofence.is_active = is_active + + if safe_commit("edit_geofence", {"geofence_id": geofence_id}): + flash(_("Geofence updated"), "success") + return redirect(url_for("admin.geofences")) + flash(_("Could not update geofence"), "error") + + return render_template( + "admin/geofences.html", + geofences=Geofence.query.order_by(Geofence.name.asc()).all(), + projects=projects, + policies=GeofencePolicy.CHOICES, + editing_geofence=geofence, + ) + + +@admin_bp.route("/admin/geofences//delete", methods=["POST"]) +@login_required +@admin_or_permission_required("access_admin") +def delete_geofence(geofence_id): + from app.models.geofence import Geofence + + geofence = Geofence.query.get_or_404(geofence_id) + db.session.delete(geofence) + if safe_commit("delete_geofence", {"geofence_id": geofence_id}): + flash(_("Geofence deleted"), "success") + else: + flash(_("Could not delete geofence"), "error") + return redirect(url_for("admin.geofences")) From 82c3a0f29d167d2da001bf4ee5ff4359f1d142d5 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:50:12 +0200 Subject: [PATCH 14/21] feat(api): expose focus, recurring-cost, and shared-report endpoints Mirror Pomodoro focus controls, recurring project cost CRUD, and saved report share/revoke under the versioned API for clients and automation. --- app/routes/api_v1.py | 287 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index c975bb90a..c3b876194 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -40,7 +40,10 @@ PurchaseOrder, RecurringBlock, RecurringInvoice, + RecurringProjectCost, SavedFilter, + SavedReportView, + SharedReportLink, StockItem, StockMovement, StockReservation, @@ -59,6 +62,7 @@ from app.models.time_entry import local_now from app.models.time_entry_approval import ApprovalStatus, TimeEntryApproval from app.services.global_search_service import run_global_search +from app.services.shared_report_service import SharedReportService from app.utils.api_auth import require_api_token from app.utils.api_responses import ( error_response, @@ -193,6 +197,7 @@ def api_info(): "audit_events": "/api/v1/reports/compliance/audit-events", }, "mileage_gps": "/api/v1/mileage/gps", + "focus_sessions": "/api/v1/focus-sessions", "search": "/api/v1/search", "inventory": { "items": "/api/v1/inventory/items", @@ -1904,6 +1909,202 @@ def delete_project_cost(cost_id): return jsonify({"message": "Project cost deleted successfully"}) +# ==================== Recurring Project Costs ==================== + + +@api_v1_bp.route("/projects//recurring-costs", methods=["GET"]) +@require_api_token("read:projects") +def list_recurring_project_costs(project_id): + """List recurring project cost templates.""" + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 50, type=int) + + query = RecurringProjectCost.query.filter(RecurringProjectCost.project_id == project_id) + is_active = request.args.get("is_active") + if is_active is not None: + query = query.filter(RecurringProjectCost.is_active == (is_active.lower() == "true")) + + query = query.order_by(RecurringProjectCost.next_run_date.asc()) + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + pagination_dict = { + "page": pagination.page, + "per_page": pagination.per_page, + "total": pagination.total, + "pages": pagination.pages, + "has_next": pagination.has_next, + "has_prev": pagination.has_prev, + "next_page": pagination.page + 1 if pagination.has_next else None, + "prev_page": pagination.page - 1 if pagination.has_prev else None, + } + return jsonify( + { + "recurring_costs": [item.to_dict() for item in pagination.items], + "pagination": pagination_dict, + } + ) + + +@api_v1_bp.route("/projects//recurring-costs", methods=["POST"]) +@require_api_token("write:projects") +def create_recurring_project_cost(project_id): + """Create a recurring project cost template.""" + data = request.get_json() or {} + required = ["description", "category", "amount", "frequency", "next_run_date"] + missing = [field for field in required if not data.get(field)] + if missing: + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + + freq = (data.get("frequency") or "").lower() + if freq not in ("daily", "weekly", "monthly", "yearly"): + return jsonify({"error": "Invalid frequency"}), 400 + + next_date = _parse_date(data.get("next_run_date")) + if not next_date: + return jsonify({"error": "Invalid next_run_date (YYYY-MM-DD)"}), 400 + + try: + amount = Decimal(str(data["amount"])) + except (ValueError, TypeError, InvalidOperation): + return jsonify({"error": "Invalid amount"}), 400 + + recurring = RecurringProjectCost( + project_id=project_id, + user_id=g.api_user.id, + description=data["description"], + category=data["category"], + amount=amount, + frequency=freq, + next_run_date=next_date, + interval=data.get("interval", 1), + end_date=_parse_date(data.get("end_date")), + billable=bool(data.get("billable", True)), + currency_code=data.get("currency_code", "EUR"), + is_active=bool(data.get("is_active", True)), + ) + db.session.add(recurring) + db.session.commit() + return jsonify({"message": "Recurring project cost created successfully", "recurring_cost": recurring.to_dict()}), 201 + + +@api_v1_bp.route("/projects//recurring-costs/", methods=["GET"]) +@require_api_token("read:projects") +def get_recurring_project_cost(project_id, rid): + recurring = RecurringProjectCost.query.filter_by(id=rid, project_id=project_id).first_or_404() + return jsonify({"recurring_cost": recurring.to_dict()}) + + +@api_v1_bp.route("/projects//recurring-costs/", methods=["PUT", "PATCH"]) +@require_api_token("write:projects") +def update_recurring_project_cost(project_id, rid): + recurring = RecurringProjectCost.query.filter_by(id=rid, project_id=project_id).first_or_404() + data = request.get_json() or {} + + for field in ("description", "category", "currency_code"): + if field in data: + setattr(recurring, field, data[field]) + if "frequency" in data and data["frequency"] in ("daily", "weekly", "monthly", "yearly"): + recurring.frequency = data["frequency"] + if "interval" in data: + try: + recurring.interval = int(data["interval"]) + except (ValueError, TypeError): + return validation_error_response({"interval": ["Invalid value."]}, message="Invalid interval") + if "next_run_date" in data: + parsed = _parse_date(data["next_run_date"]) + if parsed: + recurring.next_run_date = parsed + if "end_date" in data: + recurring.end_date = _parse_date(data["end_date"]) + for bfield in ("billable", "is_active"): + if bfield in data: + setattr(recurring, bfield, bool(data[bfield])) + if "amount" in data: + try: + recurring.amount = Decimal(str(data["amount"])) + except (ValueError, TypeError, InvalidOperation): + return validation_error_response({"amount": ["Invalid value."]}, message="Invalid amount") + + db.session.commit() + return jsonify({"message": "Recurring project cost updated successfully", "recurring_cost": recurring.to_dict()}) + + +@api_v1_bp.route("/projects//recurring-costs/", methods=["DELETE"]) +@require_api_token("write:projects") +def delete_recurring_project_cost(project_id, rid): + recurring = RecurringProjectCost.query.filter_by(id=rid, project_id=project_id).first_or_404() + db.session.delete(recurring) + db.session.commit() + return jsonify({"message": "Recurring project cost deleted successfully"}) + + +@api_v1_bp.route("/projects//recurring-costs//generate", methods=["POST"]) +@require_api_token("write:projects") +def generate_recurring_project_cost(project_id, rid): + recurring = RecurringProjectCost.query.filter_by(id=rid, project_id=project_id).first_or_404() + original_next_run_date = recurring.next_run_date + recurring.next_run_date = datetime.utcnow().date() + cost = recurring.generate_cost() + if not cost: + recurring.next_run_date = original_next_run_date + return jsonify({"message": "No cost generated (not due yet or inactive)"}), 200 + db.session.commit() + return jsonify({"message": "Project cost generated successfully", "cost": cost.to_dict()}), 201 + + +# ==================== Shared Report Links ==================== + + +@api_v1_bp.route("/reports/saved//share", methods=["POST"]) +@require_api_token("write:reports") +def create_shared_report_link(view_id): + """Create a public share link for a saved report view.""" + saved_view = SavedReportView.query.get_or_404(view_id) + if saved_view.owner_id != g.api_user.id and not g.api_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + + data = request.get_json(silent=True) or {} + expiry = (data.get("expiry") or "never").lower() + expires_in_days = None + if expiry == "7d": + expires_in_days = 7 + elif expiry == "30d": + expires_in_days = 30 + elif expiry not in ("never", ""): + return jsonify({"error": "Invalid expiry option"}), 400 + + password = (data.get("password") or "").strip() or None + service = SharedReportService() + link = service.create_link( + saved_view=saved_view, + created_by_id=g.api_user.id, + expires_in_days=expires_in_days, + password=password, + ) + db.session.commit() + url = service.get_public_url(link) + return jsonify( + { + "message": "Share link created successfully", + "link": link.to_dict(include_url=True), + "url": url, + } + ), 201 + + +@api_v1_bp.route("/reports/saved//share/", methods=["DELETE"]) +@require_api_token("write:reports") +def revoke_shared_report_link(view_id, token): + """Revoke a shared report link.""" + saved_view = SavedReportView.query.get_or_404(view_id) + if saved_view.owner_id != g.api_user.id and not g.api_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + + service = SharedReportService() + if not service.revoke_link(view_id, token): + return jsonify({"error": "Share link not found"}), 404 + return jsonify({"message": "Share link revoked successfully"}) + + # ==================== Tax Rules (Admin) ==================== @@ -5210,6 +5411,92 @@ def mileage_gps_list_api(): return jsonify({"tracks": tracks}) +# ==================== Focus / Pomodoro Sessions ==================== + + +@api_v1_bp.route("/focus-sessions/active", methods=["GET"]) +@require_api_token("read:time_entries") +def api_v1_focus_active(): + from app.services.pomodoro_service import PomodoroService + + session = PomodoroService().get_active_session(g.api_user.id) + return jsonify({"session": session.to_dict() if session else None}) + + +@api_v1_bp.route("/focus-sessions/start", methods=["POST"]) +@require_api_token("write:time_entries") +def api_v1_focus_start(): + from app.services.pomodoro_service import PomodoroService + + data = request.get_json() or {} + user = g.api_user + result = PomodoroService().start_session( + user_id=user.id, + project_id=data.get("project_id"), + task_id=data.get("task_id"), + pomodoro_length=int(data.get("pomodoro_length") or getattr(user, "pomodoro_length", None) or 25), + short_break_length=int(data.get("short_break_length") or getattr(user, "pomodoro_short_break", None) or 5), + long_break_length=int(data.get("long_break_length") or getattr(user, "pomodoro_long_break", None) or 15), + long_break_interval=int( + data.get("long_break_interval") or getattr(user, "pomodoro_long_break_interval", None) or 4 + ), + ) + if not result.get("success"): + return jsonify(result), 409 + return jsonify(result), 201 + + +@api_v1_bp.route("/focus-sessions//cycle", methods=["POST"]) +@require_api_token("write:time_entries") +def api_v1_focus_cycle(session_id): + from app.services.pomodoro_service import PomodoroService + + fs = FocusSession.query.get_or_404(session_id) + if fs.user_id != g.api_user.id and not g.api_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + if fs.ended_at: + return jsonify({"error": "Session already ended"}), 400 + return jsonify(PomodoroService().complete_cycle(session_id)) + + +@api_v1_bp.route("/focus-sessions//interrupt", methods=["POST"]) +@require_api_token("write:time_entries") +def api_v1_focus_interrupt(session_id): + from app.services.pomodoro_service import PomodoroService + + data = request.get_json() or {} + fs = FocusSession.query.get_or_404(session_id) + if fs.user_id != g.api_user.id and not g.api_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + if fs.ended_at: + return jsonify({"error": "Session already ended"}), 400 + return jsonify(PomodoroService().log_interruption(session_id, reason=data.get("reason"))) + + +@api_v1_bp.route("/focus-sessions/finish", methods=["POST"]) +@require_api_token("write:time_entries") +def api_v1_focus_finish(): + from app.services.pomodoro_service import PomodoroService + + data = request.get_json() or {} + session_id = data.get("session_id") + if not session_id: + return jsonify({"error": "session_id is required"}), 400 + fs = FocusSession.query.get_or_404(session_id) + if fs.user_id != g.api_user.id and not g.api_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + return jsonify(PomodoroService().end_session(session_id, notes=(data.get("notes") or "").strip() or None)) + + +@api_v1_bp.route("/focus-sessions/summary", methods=["GET"]) +@require_api_token("read:time_entries") +def api_v1_focus_summary(): + from app.services.pomodoro_service import PomodoroService + + days = int(request.args.get("days", 7)) + return jsonify(PomodoroService().get_session_stats(g.api_user.id, days=days)) + + # ==================== Error Handlers ==================== From aa83c616f068f9948e50606fe7dd7bfbf32d3eca Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:50:12 +0200 Subject: [PATCH 15/21] feat: register gap features and surface them on the dashboard Wire gamification/geofence blueprints, schedule leaderboard and recurring cost jobs, and show focus plus badge stats on the main dashboard. --- app/blueprint_registry.py | 3 ++ app/routes/main.py | 32 ++++++++++++++ app/templates/main/dashboard.html | 34 +++++++++++++++ app/utils/scheduled_tasks.py | 70 ++++++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 1 deletion(-) diff --git a/app/blueprint_registry.py b/app/blueprint_registry.py index 341e894ed..d2d420e24 100644 --- a/app/blueprint_registry.py +++ b/app/blueprint_registry.py @@ -57,6 +57,7 @@ def register_all_blueprints(app, logger=None): from app.routes.api_v1 import api_v1_bp import_module("app.routes.api_v1_workday") + import_module("app.routes.api_v1_geofences") import_module("app.routes.api_v1_attendance") from app.routes.api_v1_ai import api_v1_ai_bp from app.routes.api_v1_clients import api_v1_clients_bp @@ -114,6 +115,7 @@ def register_all_blueprints(app, logger=None): from app.routes.webhooks import webhooks_bp from app.routes.weekly_goals import weekly_goals_bp from app.routes.workday import workday_bp + from app.routes.gamification import gamification_bp try: from app.routes.audit_logs import audit_logs_bp @@ -202,6 +204,7 @@ def register_all_blueprints(app, logger=None): app.register_blueprint(expenses_bp) app.register_blueprint(permissions_bp) app.register_blueprint(calendar_bp) + app.register_blueprint(gamification_bp) app.register_blueprint(expense_categories_bp) app.register_blueprint(mileage_bp) app.register_blueprint(per_diem_bp) diff --git a/app/routes/main.py b/app/routes/main.py index 7d5d90036..e866e7e78 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -104,6 +104,33 @@ def dashboard(): workday_week_hours = stats.get("workday_hours", {}).get("week", 0.0) workday_month_hours = stats.get("workday_hours", {}).get("month", 0.0) + # Focus / Pomodoro today stats + from app.models.focus_session import FocusSession + + today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + focus_today = FocusSession.query.filter( + FocusSession.user_id == current_user.id, + FocusSession.started_at >= today_start, + ).all() + focus_today_sessions = len(focus_today) + focus_today_cycles = sum(s.cycles_completed or 0 for s in focus_today) + focus_today_minutes = 0 + for s in focus_today: + end = s.ended_at or datetime.utcnow() + if s.started_at: + focus_today_minutes += int((end - s.started_at).total_seconds() / 60) + + gamification_points = 0 + gamification_badge_count = 0 + try: + from app.services.gamification_service import GamificationService + + gsvc = GamificationService() + gamification_points = gsvc.get_user_points(current_user.id) + gamification_badge_count = len(gsvc.get_user_badges(current_user.id)) + except Exception: + pass + from app.services.attendance_compliance_service import AttendanceComplianceService from app.services.workday_session_service import WorkdaySessionService from app.services.working_time_limit_service import WorkingTimeLimitService @@ -476,6 +503,11 @@ def dashboard(): "week_hours": week_hours, "month_hours": month_hours, "utilization": utilization, + "focus_today_cycles": focus_today_cycles, + "focus_today_minutes": focus_today_minutes, + "focus_today_sessions": focus_today_sessions, + "gamification_points": gamification_points, + "gamification_badge_count": gamification_badge_count, "standard_hours_per_day": standard_hours_per_day, "today_regular_hours": today_overtime["regular_hours"], "today_overtime_hours": today_overtime["overtime_hours"], diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index 605f095ae..eb8d8b4b0 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -417,6 +417,40 @@

{{ _( {{ _('Open report') }}

{% endif %} +
+
+
+

{{ _('Focus today') }}

+
+ {{ focus_today_cycles|default(0) }} + {{ _('cycles') }} +
+

+ {{ focus_today_minutes|default(0) }} {{ _('min') }} · {{ focus_today_sessions|default(0) }} {{ _('sessions') }} +

+
+
+ +
+
+ {{ _('Open timer') }} +
+
+
+
+

{{ _('Badges') }}

+
+ {{ gamification_points|default(0) }} + {{ _('pts') }} +
+

{{ gamification_badge_count|default(0) }} {{ _('earned') }}

+
+
+ +
+
+ {{ _('Leaderboard') }} +
diff --git a/app/utils/scheduled_tasks.py b/app/utils/scheduled_tasks.py index eed9c8d37..ee33d1bf9 100644 --- a/app/utils/scheduled_tasks.py +++ b/app/utils/scheduled_tasks.py @@ -323,6 +323,20 @@ def generate_recurring_invoices(): return 0 +def generate_recurring_project_costs(): + """Generate project costs from active recurring cost templates.""" + try: + logger.info("Generating recurring project costs...") + from app.services.recurring_project_cost_service import RecurringProjectCostService + + generated = RecurringProjectCostService().generate_due_recurring_costs() + logger.info("Generated %s recurring project costs", generated) + return generated + except Exception as e: + logger.error(f"Error generating recurring project costs: {e}") + return 0 + + def send_monthly_unpaid_hours_reports(): """Send monthly unpaid hours reports split by salesman @@ -550,7 +564,28 @@ def generate_recurring_invoices_with_app(): replace_existing=True, ) logger.info("Registered recurring invoices generation task") - logger.info("Registered recurring invoices generation task") + + def generate_recurring_project_costs_with_app(): + app_instance = app + if app_instance is None: + try: + app_instance = current_app._get_current_object() + except RuntimeError: + logger.error("No app instance available for recurring project costs generation") + return + with external_url_context(app_instance): + generate_recurring_project_costs() + + scheduler.add_job( + func=generate_recurring_project_costs_with_app, + trigger="cron", + hour=8, + minute=15, + id="generate_recurring_project_costs", + name="Generate recurring project costs", + replace_existing=True, + ) + logger.info("Registered recurring project costs generation task") # Send monthly unpaid hours reports by salesman (first day of month at 9 AM) def send_monthly_unpaid_hours_reports_with_app(): @@ -871,6 +906,39 @@ def post_slack_daily_summaries_with_app(): ) logger.info("Registered Slack daily summary task") + def recalculate_gamification_leaderboards_with_app(): + app_instance = app + if app_instance is None: + try: + app_instance = current_app._get_current_object() + except RuntimeError: + logger.error("No app instance available for gamification leaderboards") + return + with app_instance.app_context(): + try: + from app.models.gamification import Leaderboard + from app.services.gamification_service import GamificationService + + svc = GamificationService() + for board in Leaderboard.query.filter_by(is_active=True).all(): + try: + svc.calculate_leaderboard(board.id) + except Exception: + logger.debug("Leaderboard calc failed for %s", board.id, exc_info=True) + except Exception: + logger.debug("Gamification leaderboard job failed", exc_info=True) + + scheduler.add_job( + func=recalculate_gamification_leaderboards_with_app, + trigger="cron", + hour=3, + minute=15, + id="recalculate_gamification_leaderboards", + name="Recalculate gamification leaderboards daily", + replace_existing=True, + ) + logger.info("Registered gamification leaderboard task") + try: from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED From 0e217e69e3184477246b6d83f3065667845858fe Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:50:12 +0200 Subject: [PATCH 16/21] feat(mobile): extend API client for expenses, reports, and geofencing Add expense lifecycle helpers, report summary access, and lat/lng on workday start/end so Flutter can use the new backend capabilities. --- mobile/lib/data/api/api_client.dart | 73 +++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/mobile/lib/data/api/api_client.dart b/mobile/lib/data/api/api_client.dart index 1951460f5..12bbf95be 100644 --- a/mobile/lib/data/api/api_client.dart +++ b/mobile/lib/data/api/api_client.dart @@ -435,6 +435,42 @@ class ApiClient { return Map.from(res.data ?? {}); } + Future> getExpense(int expenseId) async { + final res = await _dio.get>('/api/v1/expenses/$expenseId'); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> updateExpense(int expenseId, Map body) async { + final res = await _dio.patch>('/api/v1/expenses/$expenseId', data: body); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> deleteExpense(int expenseId) async { + final res = await _dio.delete>('/api/v1/expenses/$expenseId'); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> approveExpense(int expenseId, {String? notes}) async { + final res = await _dio.post>( + '/api/v1/expenses/$expenseId/approve', + data: {if (notes != null) 'notes': notes}, + ); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + + Future> rejectExpense(int expenseId, {required String reason}) async { + final res = await _dio.post>( + '/api/v1/expenses/$expenseId/reject', + data: {'reason': reason}, + ); + _throwIfError(res); + return Map.from(res.data ?? {}); + } + Future> createInvoice(Map body) async { final res = await _dio.post>('/api/v1/invoices', data: body); _throwIfError(res); @@ -653,27 +689,58 @@ class ApiClient { return _unwrapData(res.data); } - Future> startWorkday({String? notes, String source = 'mobile'}) async { + Future> startWorkday({ + String? notes, + String source = 'mobile', + double? latitude, + double? longitude, + double? accuracyM, + }) async { final res = await _dio.post>( '/api/v1/workday/start', data: { if (notes != null) 'notes': notes, 'source': source, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (accuracyM != null) 'accuracy_m': accuracyM, }, ); _throwIfError(res); return _unwrapData(res.data); } - Future> endWorkday({String? notes}) async { + Future> endWorkday({ + String? notes, + double? latitude, + double? longitude, + double? accuracyM, + }) async { final res = await _dio.post>( '/api/v1/workday/end', - data: {if (notes != null) 'notes': notes}, + data: { + if (notes != null) 'notes': notes, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (accuracyM != null) 'accuracy_m': accuracyM, + }, ); _throwIfError(res); return _unwrapData(res.data); } + Future>> getGeofences() async { + final res = await _dio.get>('/api/v1/geofences'); + _throwIfError(res); + final data = _unwrapData(res.data); + final raw = data['geofences'] ?? const []; + if (raw is! List) return const []; + return raw + .whereType() + .map((e) => Map.from(e)) + .toList(); + } + Future> startAttendanceBreak({String breakType = 'rest'}) async { final res = await _dio.post>( '/api/v1/attendance/break/start', From b7909662b57837d20b6e5fe9dec002c6ec7ecd50 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 06:53:37 +0200 Subject: [PATCH 17/21] fix(inventory): correct PO line stock IDs and restore edit form (#759) Remove the duplicate item_stock_item_id[] field that misaligned multi-item stock links, and make the shared PO form post to edit with existing values and line items prefilled. --- .../inventory/purchase_orders/form.html | 69 ++++++++++++------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/app/templates/inventory/purchase_orders/form.html b/app/templates/inventory/purchase_orders/form.html index 2799cd9ad..6836aed5e 100644 --- a/app/templates/inventory/purchase_orders/form.html +++ b/app/templates/inventory/purchase_orders/form.html @@ -2,21 +2,22 @@ {% from "components/ui.html" import page_header %} {% block content %} +{% set is_edit = purchase_order is not none %} {% set breadcrumbs = [ {'text': 'Inventory', 'url': url_for('inventory.list_stock_items')}, {'text': 'Purchase Orders', 'url': url_for('inventory.list_purchase_orders')}, - {'text': 'New Purchase Order'} + {'text': _('Edit Purchase Order') if is_edit else _('New Purchase Order')} ] %} {{ page_header( icon_class='fas fa-shopping-cart', - title_text='New Purchase Order', - subtitle_text='Create a new purchase order to a supplier', + title_text=_('Edit Purchase Order') if is_edit else _('New Purchase Order'), + subtitle_text=_('Update purchase order details') if is_edit else _('Create a new purchase order to a supplier'), breadcrumbs=breadcrumbs ) }}
-
+ @@ -27,37 +28,42 @@

- {% for supplier in suppliers %} - + {% endfor %} + {% if is_edit %} + {# Disabled selects are not submitted; keep supplier_id for any client-side use #} + + {% endif %}
- +
- +
+ {% set selected_currency = request.form.get('currency_code', purchase_order.currency_code if is_edit else 'EUR') %}
- +
- +

@@ -97,11 +103,11 @@

- + {{ _('Cancel') }}
@@ -112,13 +118,19 @@

document.addEventListener('DOMContentLoaded', function() { const itemsContainer = document.getElementById('po-items'); const addItemBtn = document.getElementById('add-item'); - const supplierSelect = document.getElementById('supplier_id'); const stockItems = {{ stock_items | tojson if stock_items else '[]' }}; const warehouses = {{ warehouses | tojson if warehouses else '[]' }}; + const existingItems = [ + {% if is_edit %} + {% for item in purchase_order.items %} + {{ item.to_dict() | tojson }}{% if not loop.last %},{% endif %} + {% endfor %} + {% endif %} + ]; // Build stock items dropdown HTML - function buildStockItemsHtml(selectedSupplierId = null) { + function buildStockItemsHtml() { let html = ''; stockItems.forEach(item => { html += ''; @@ -141,14 +153,13 @@

row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 po-item-row'; row.innerHTML = - '' + '' + '' + '' + '' + '' + '
0.00
' + - '' + + '' + '' + ' + +
@@ -220,6 +236,24 @@

{{ _('Start New Timer') }}

+ +
+ +
+ + +
+

+ {{ _('Leave blank to start now, or enter a past time to backdate.') }} +

+
{% if templates %}
@@ -500,6 +534,23 @@

{{ _("Today's Stats") }}

} }; +function toDatetimeLocalValue(date) { + const pad = (n) => String(n).padStart(2, '0'); + return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()); +} + +const startOverrideInput = document.getElementById('start_time_override'); +const resetStartBtn = document.getElementById('btn-reset-start-time'); +if (startOverrideInput) { + startOverrideInput.value = toDatetimeLocalValue(new Date()); +} +if (resetStartBtn && startOverrideInput) { + resetStartBtn.addEventListener('click', function() { + startOverrideInput.value = toDatetimeLocalValue(new Date()); + }); +} + }); // end DOMContentLoaded // Update timer display every second @@ -557,6 +608,81 @@

{{ _("Today's Stats") }}

} } +function formatStartDisplay(isoString) { + try { + const d = new Date(isoString); + if (Number.isNaN(d.getTime())) return isoString; + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } catch (_) { + return isoString; + } +} + +(function initEditStartTime() { + const editBtn = document.getElementById('btn-edit-start'); + const cancelBtn = document.getElementById('btn-cancel-edit'); + const form = document.getElementById('edit-start-form'); + const input = document.getElementById('edit-start-input'); + const display = document.getElementById('timer-start-display'); + if (!editBtn || !form || !input) return; + + function pad(n) { return String(n).padStart(2, '0'); } + function toLocalValue(date) { + return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()); + } + + editBtn.addEventListener('click', function() { + const iso = input.getAttribute('data-start-iso') || '{{ active_timer.start_time.isoformat() }}'; + const d = new Date(iso); + if (!Number.isNaN(d.getTime())) input.value = toLocalValue(d); + form.classList.remove('hidden'); + editBtn.classList.add('hidden'); + input.focus(); + }); + + if (cancelBtn) { + cancelBtn.addEventListener('click', function() { + form.classList.add('hidden'); + editBtn.classList.remove('hidden'); + }); + } + + form.addEventListener('submit', async function(e) { + e.preventDefault(); + const csrf = form.querySelector('input[name="csrf_token"]'); + const body = new FormData(); + body.append('start_time', input.value); + if (csrf) body.append('csrf_token', csrf.value); + try { + const headers = { 'X-Requested-With': 'XMLHttpRequest' }; + if (csrf) headers['X-CSRFToken'] = csrf.value; + const res = await fetch(form.action, { + method: 'POST', + credentials: 'same-origin', + headers: headers, + body: body, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.success) { + throw new Error(data.error || '{{ _("Failed to update start time") }}'); + } + startTime = new Date(data.start_time).getTime(); + input.setAttribute('data-start-iso', data.start_time); + if (display) display.textContent = formatStartDisplay(data.start_time); + form.classList.add('hidden'); + editBtn.classList.remove('hidden'); + updateTimer(); + } catch (err) { + if (window.toastManager && typeof window.toastManager.error === 'function') { + window.toastManager.error(err.message, '{{ _("Error") }}', 5000); + } else { + alert(err.message); + } + } + }); +})(); + // Start timer update timerInterval = setInterval(updateTimer, 1000); updateTimer(); // Initial update From 5eead2c0d85ae1bc9a8a089774f8e0687bf04e38 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 07:37:13 +0200 Subject: [PATCH 19/21] fix(migrations): use boolean literals for PostgreSQL server defaults Integer defaults on BOOLEAN columns (0/1) caused DatatypeMismatch errors and blocked app startup on Postgres. --- migrations/versions/186_add_payroll_export_templates.py | 4 ++-- migrations/versions/187_add_integration_sync_errors.py | 2 +- migrations/versions/188_add_activitywatch_rules.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/migrations/versions/186_add_payroll_export_templates.py b/migrations/versions/186_add_payroll_export_templates.py index 34c81ba05..736c3dd9e 100644 --- a/migrations/versions/186_add_payroll_export_templates.py +++ b/migrations/versions/186_add_payroll_export_templates.py @@ -27,8 +27,8 @@ def upgrade(): sa.Column("filters", sa.JSON(), nullable=True), sa.Column("format", sa.String(length=10), nullable=False, server_default="csv"), sa.Column("delimiter", sa.String(length=5), nullable=False, server_default=","), - sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("0")), - sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.text("false")), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), ) diff --git a/migrations/versions/187_add_integration_sync_errors.py b/migrations/versions/187_add_integration_sync_errors.py index 7599b4c78..0fd8ddc21 100644 --- a/migrations/versions/187_add_integration_sync_errors.py +++ b/migrations/versions/187_add_integration_sync_errors.py @@ -26,7 +26,7 @@ def upgrade(): sa.Column("entity_id", sa.Integer(), nullable=True), sa.Column("error_message", sa.Text(), nullable=False), sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"), - sa.Column("resolved", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("resolved", sa.Boolean(), nullable=False, server_default=sa.text("false")), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), sa.Column("last_retry_at", sa.DateTime(), nullable=True), diff --git a/migrations/versions/188_add_activitywatch_rules.py b/migrations/versions/188_add_activitywatch_rules.py index a47bff136..726c1d6fe 100644 --- a/migrations/versions/188_add_activitywatch_rules.py +++ b/migrations/versions/188_add_activitywatch_rules.py @@ -29,10 +29,10 @@ def upgrade(): sa.Column("project_id", sa.Integer(), sa.ForeignKey("projects.id"), nullable=True), sa.Column("task_id", sa.Integer(), sa.ForeignKey("tasks.id"), nullable=True), sa.Column("tags", sa.JSON(), nullable=True), - sa.Column("billable", sa.Boolean(), nullable=False, server_default=sa.text("1")), + sa.Column("billable", sa.Boolean(), nullable=False, server_default=sa.text("true")), sa.Column("min_duration_seconds", sa.Integer(), nullable=True), sa.Column("priority", sa.Integer(), nullable=False, server_default="100"), - sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), ) @@ -51,7 +51,7 @@ def upgrade(): sa.Column("matched_rule_id", sa.Integer(), sa.ForeignKey("activitywatch_rules.id"), nullable=True), sa.Column("suggested_project_id", sa.Integer(), sa.ForeignKey("projects.id"), nullable=True), sa.Column("suggested_task_id", sa.Integer(), sa.ForeignKey("tasks.id"), nullable=True), - sa.Column("suggested_billable", sa.Boolean(), nullable=False, server_default=sa.text("1")), + sa.Column("suggested_billable", sa.Boolean(), nullable=False, server_default=sa.text("true")), sa.Column("external_uid", sa.String(255), nullable=True), sa.Column("status", sa.String(20), nullable=False, server_default="pending"), sa.Column("notes", sa.Text(), nullable=True), From fc9477964b7058ce51dccf8e7606d44ef2f1e2e8 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 07:47:46 +0200 Subject: [PATCH 20/21] fix(dashboard): use correct timer_page endpoint for Open timer link Prevents BuildError when rendering the dashboard after the timer view was renamed. --- app/templates/main/dashboard.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index eb8d8b4b0..db297aa3c 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -433,7 +433,7 @@

{{ _(

- {{ _('Open timer') }} + {{ _('Open timer') }}
From f4def7cb0147ae1710b7791042aaa43f0c1d1482 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 16 Sep 2026 08:44:19 +0200 Subject: [PATCH 21/21] =?UTF-8?q?chore:=20release=205.15.0=20=E2=80=94=20t?= =?UTF-8?q?imer=20override,=20Pomodoro,=20expense=20lifecycle,=20payroll?= =?UTF-8?q?=20templates,=20geofencing,=20gamification,=20calendar=20DnD,?= =?UTF-8?q?=20shareable=20reports,=20recurring=20costs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ README.md | 4 ++++ setup.py | 2 +- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 464a3bd2a..fd48b9c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.15.0] - 2026-09-16 + +### Added + +- **Timer start-time override (#760)** — Start a new timer at a custom time, or adjust the start while already running; the timer page and API both support the override. +- **Pomodoro / Focus sessions** — Pomodoro sessions are now wired into the timer API and UI so focus blocks are tracked and surfaced alongside regular time entries. +- **Expense lifecycle on mobile & desktop** — Full expense CRUD (submit, approve, reject, reimburse) now available in the Flutter mobile and Electron desktop clients. +- **Payroll export templates** — Admins can configure reusable payroll export templates (admin panel + geofence management pages); exports are rendered via the template engine. +- **QuickBooks & Xero sync depth** — Extended accounting sync covers additional entity types, mapping, and error-route handling for both integrations. +- **ActivityWatch rules, inbox & sync errors** — Rules engine and merge filters for ActivityWatch events; a dedicated review inbox and sync-error route surface unmatched entries. +- **Gamification — badges, leaderboards & award hooks** — Badge definitions, leaderboard calculations, and award-hook endpoints exposed via the API for team engagement features. +- **Calendar drag-and-drop** — Events on the calendar view can be moved and resized by dragging, updating the underlying time entry in real time. +- **Recurring project cost engine** — Projects can have recurring costs that are calculated and posted automatically on a configured schedule. +- **Public shareable report links** — Reports can be shared via a public, tokenized URL without requiring the recipient to log in. +- **Geofencing policies** — Attendance module gains location-based clock-in policies; admin UI added for geofence management. +- **Gap-feature models & dashboard surface** — New database models and migrations for the product-gap backlog; key features registered and surfaced on the main dashboard. +- **Mobile API extensions** — Flutter client extended with expense, report, and geofencing API calls. +- **New API endpoints** — Focus sessions, recurring project costs, and shared report endpoints added to the REST API. + +### Fixed + +- **Inventory PO lines & edit form (#759)** — Correct stock IDs on PO lines and restore the broken edit form. +- **Migrations PostgreSQL boolean defaults** — Migration server defaults now use `true`/`false` literals instead of `1`/`0` for PostgreSQL compatibility. +- **Dashboard Open Timer link** — Use the correct `timer_page` endpoint for the "Open timer" link so it no longer 404s. + +### Changed + +- **Dependencies** — Bumped `weasyprint` (pip group). + +### Documentation + +- **Version** — Bumped `setup.py` to **5.15.0** (single source of truth for the application version). + ## [5.14.1] - 2026-09-09 ### Fixed diff --git a/README.md b/README.md index 89ebd6489..04821d8cb 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,10 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh **Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for versioned release history. +### ✨ Highlights of v5.15.0 + +**Minor (5.15.0):** **Timer start override (#760)** — start or adjust a running timer at a custom time. **Pomodoro sessions** — focus blocks tracked via timer API and UI. **Expense lifecycle** — full mobile/desktop expense CRUD. **Payroll templates** — configurable export templates. **QuickBooks & Xero** — deeper accounting sync. **ActivityWatch inbox** — rules, merge filters, and sync-error review. **Gamification** — badges, leaderboards, and award hooks. **Calendar DnD** — drag-to-move and resize events. **Recurring costs** — automatic recurring project cost engine. **Shareable reports** — public tokenized report links. **Geofencing** — location-based attendance clock-in policies. See [CHANGELOG.md](CHANGELOG.md#5150---2026-09-16). + ### ✨ Highlights of v5.14.1 **Patch (5.14.1):** **Docker base images** — Node 20 and Python Bookworm replace expired Bullseye bases so fresh CI/image builds succeed. See [CHANGELOG.md](CHANGELOG.md#5141---2026-09-09). diff --git a/setup.py b/setup.py index 04fcabc88..72ff5fd2c 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='timetracker', - version='5.14.1', + version='5.15.0', packages=find_packages(), include_package_data=True, package_data={