diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1357b0aa..698aede1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [5.13.0] - 2026-08-23
+
+### Added
+
+- **Dashboard daily progress widget** — Compact strip showing today's tracked hours vs target with a quick-start timer button directly on the dashboard.
+- **Dashboard quick-log & tasks due today** — One-click time log form and a "Tasks due today" widget surface the most time-sensitive work without leaving the home screen.
+- **Backend dashboard alerts & productivity breakdown** — Server-side alert rules for overdue timers, approaching daily targets, and idle sessions; new productivity breakdown API for the dashboard.
+- **Timer long-run warning** — Active timers exceeding a configurable threshold display an in-page warning and show workday progress against the daily goal.
+
+### Changed
+
+- **Dashboard & list UX polish** — Consolidated dashboard alert banners, list-view sort/filter controls, and empty-state illustrations unified across all entity lists.
+- **Navigation declutter** — Sidebar collapsed-mode tooltips and mobile "More" drawer reorganised; key destinations (Reports, CRM, Finance) promoted to top-level slots.
+- **Recurring tasks list** — Added status filter chips, column-level toggles (pause/resume/run now), and an inline "Run now" action without leaving the list.
+- **Floating timer bar & idle-stop** — Redesigned compact bar with project/task labels, elapsed counter, and a smoother idle-boundary stop flow.
+- **Client versions** — Synced Electron (`desktop/package.json`), Flutter (`mobile/pubspec.yaml`), and Chromium extension (`browser-extension/manifest.json` / `package.json`) to **5.13.0** with the webapp (`setup.py`).
+
+### Fixed
+
+- **Productivity heatmap** — Corrected heatmap data aggregation and resolved `empty_state` template render error.
+- **Invoice tax rate & currency** — Tax rate and currency now resolve correctly from the linked project and client settings when generating an invoice.
+- **Swallowed errors (#736)** — Previously silent exceptions now log at the appropriate level; actionable failures surface feedback toasts to the user.
+- **Timer heartbeats (#736)** — Heartbeat pings stay alive through network interruptions and the timer stops cleanly at the idle boundary.
+- **Client portal admin template** — Added the missing customisation admin template that caused a 500 on the portal settings page.
+- **Recurring tasks CRUD** — Completed create/edit/delete flow and restored sidebar navigation link for recurring tasks.
+
+### Documentation
+
+- **Version** — Bumped `setup.py` to **5.13.0** (single source of truth for the application version).
+
## [5.12.0] - 2026-08-20
### Added
diff --git a/README.md b/README.md
index 437ac153..ecf9bea1 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.13.0
+
+**Minor (5.13.0):** **Daily progress widget** — compact strip on the dashboard with today's hours vs target and a quick-start timer. **Quick-log & tasks due today** — one-click log form and due-today task widget without leaving home. **Timer long-run warning** — alert when a timer exceeds the configurable threshold, with workday progress. **Navigation declutter** — sidebar and mobile More drawer reorganised with key destinations promoted. **Recurring tasks list** — status filters, pause/resume/run-now inline actions. **Floating timer bar & idle-stop** — redesigned compact bar and smoother idle boundary. **Fixes** — productivity heatmap, invoice tax rate/currency resolution, swallowed errors (#736), timer heartbeats (#736), portal admin template, recurring tasks CRUD. See [CHANGELOG.md](CHANGELOG.md#5130---2026-08-23).
+
### ✨ Highlights of v5.12.0
**Minor (5.12.0):** **Task dependencies & Gantt links** — blockers between tasks with arrows on the chart. **Milestones** — named due-date checkpoints and project timeline. **Utilization report** — billable vs total hours by user or project. **Expense approvals** — manager queue with bulk-approve and pending badge. **Quote signatures** — in-portal sign/accept with PDF embed. **Resource scheduling** — weekly capacity swimlanes. **Project health** — budget burn, completion, milestones, and overdue work in one view. **Fixes** — dashboard Start Timer modal (#734), PostgreSQL approval status enum case. See [CHANGELOG.md](CHANGELOG.md#5120---2026-08-20).
diff --git a/app/routes/api.py b/app/routes/api.py
index 5fa1d25c..824c5eeb 100644
--- a/app/routes/api.py
+++ b/app/routes/api.py
@@ -342,6 +342,40 @@ def timer_status():
)
+@api_bp.route("/api/timer/notes-suggestions")
+@login_required
+def timer_notes_suggestions():
+ """Recent time-entry notes for description autocomplete, optionally scoped to a project."""
+ project_id = request.args.get("project_id", type=int)
+ q = (
+ db.session.query(TimeEntry.notes)
+ .filter(
+ TimeEntry.user_id == current_user.id,
+ TimeEntry.notes.isnot(None),
+ TimeEntry.notes != "",
+ )
+ )
+ if project_id:
+ q = q.filter(TimeEntry.project_id == project_id)
+ rows = q.order_by(TimeEntry.updated_at.desc()).limit(150).all()
+
+ seen = set()
+ suggestions = []
+ for (notes,) in rows:
+ text = (notes or "").strip()
+ if not text:
+ continue
+ key = text.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ suggestions.append(text[:200])
+ if len(suggestions) >= 20:
+ break
+
+ return jsonify({"ok": True, "suggestions": suggestions})
+
+
@api_bp.route("/api/timer/heartbeat", methods=["POST"])
@login_required
@deprecated_session_api("/api/v1/timer/heartbeat")
@@ -2491,6 +2525,7 @@ def productivity_stats():
summary = ProductivityService.get_summary(current_user)
daily_breakdown = ProductivityService.get_daily_breakdown(current_user, days=14)
+ daily_project_breakdown = ProductivityService.get_daily_project_breakdown(current_user, days=14)
streak = ProductivityService.get_streak(current_user)
focus = ProductivityService.get_focus_stats(current_user, days=period)
projects = ProductivityService.get_project_breakdown(current_user, days=period)
@@ -2502,6 +2537,7 @@ def productivity_stats():
"period": period,
"summary": summary,
"daily_breakdown": daily_breakdown,
+ "daily_project_breakdown": daily_project_breakdown,
"streak": streak,
"focus": focus,
"projects": projects,
diff --git a/app/routes/api_v1_mileage.py b/app/routes/api_v1_mileage.py
index 7be5eac4..9d616089 100644
--- a/app/routes/api_v1_mileage.py
+++ b/app/routes/api_v1_mileage.py
@@ -153,12 +153,21 @@ def update_mileage(entry_id):
parsed = _parse_date(data["trip_date"])
if parsed:
entry.trip_date = parsed
+ invalid_numeric_fields = []
for numfield in ("distance_km", "rate_per_km", "start_odometer", "end_odometer"):
if numfield in data:
try:
setattr(entry, numfield, Decimal(str(data[numfield])))
except Exception:
- pass
+ invalid_numeric_fields.append(numfield)
+ if invalid_numeric_fields:
+ return jsonify(
+ {
+ "error": "Invalid numeric value",
+ "fields": invalid_numeric_fields,
+ "message": f"Invalid value for: {', '.join(invalid_numeric_fields)}",
+ }
+ ), 400
if "is_round_trip" in data:
entry.is_round_trip = bool(data["is_round_trip"])
if "distance_km" in data or "rate_per_km" in data:
diff --git a/app/routes/expenses.py b/app/routes/expenses.py
index a0595763..577cb295 100644
--- a/app/routes/expenses.py
+++ b/app/routes/expenses.py
@@ -953,8 +953,13 @@ def approve_expense(expense_id):
from app.utils.integration_sync_hooks import trigger_expense_sync
trigger_expense_sync(expense)
- except Exception:
- pass
+ except Exception as sync_error:
+ current_app.logger.warning(
+ "Expense approved but integration sync failed for expense %s: %s",
+ expense_id,
+ sync_error,
+ exc_info=True,
+ )
else:
flash(_("Error approving expense"), "error")
diff --git a/app/routes/main.py b/app/routes/main.py
index 50a596d6..7d5d9003 100644
--- a/app/routes/main.py
+++ b/app/routes/main.py
@@ -16,10 +16,11 @@
)
from flask_babel import gettext as _
from flask_login import current_user, login_required
-from sqlalchemy import text
+from sqlalchemy import or_, text
+from sqlalchemy.orm import joinedload
from app import csrf, db, limiter, track_event, track_page_view
-from app.models import Activity, Client, Project, Settings, TimeEntry, TimeEntryTemplate, User, WeeklyTimeGoal
+from app.models import Activity, Client, Invoice, Milestone, Project, Settings, Task, TimeEntry, TimeEntryTemplate, User, WeeklyTimeGoal
from app.models.time_entry import local_now
from app.utils.license_utils import is_license_activated
from app.utils.posthog_segmentation import update_user_segments_if_needed
@@ -219,6 +220,153 @@ def dashboard():
"client_name": last_entry.client.name if last_entry.client else None,
}
+ # Recent project+task combos for quick-start strip (last 5 unique pairs)
+ recent_combos = []
+ combo_seen = set()
+ combo_entries = (
+ TimeEntry.query.options(joinedload(TimeEntry.project), joinedload(TimeEntry.task))
+ .filter(
+ TimeEntry.user_id == current_user.id,
+ TimeEntry.end_time.isnot(None),
+ TimeEntry.project_id.isnot(None),
+ )
+ .order_by(TimeEntry.end_time.desc())
+ .limit(50)
+ .all()
+ )
+ for combo_entry in combo_entries:
+ combo_key = (combo_entry.project_id, combo_entry.task_id)
+ if combo_key in combo_seen:
+ continue
+ combo_seen.add(combo_key)
+ recent_combos.append(
+ {
+ "project_id": combo_entry.project_id,
+ "task_id": combo_entry.task_id,
+ "project_name": combo_entry.project.name if combo_entry.project else None,
+ "task_name": combo_entry.task.name if combo_entry.task else None,
+ }
+ )
+ if len(recent_combos) >= 5:
+ break
+
+ today_seconds = int(round(today_hours * 3600))
+ daily_target_seconds = int(round(standard_hours_per_day * 3600))
+
+ from app.services.utilization_service import UtilizationService
+
+ week_utilization = UtilizationService.for_user_period(
+ current_user.id,
+ datetime.combine(week_start_dt, datetime.min.time()),
+ datetime.combine(today_dt, datetime.max.time()),
+ )
+ is_past_midday = local_now().hour >= 12
+
+ # Tasks due today or overdue (assigned to or created by current user)
+ today_date = local_now().date()
+ tasks_due_query = (
+ Task.query.options(joinedload(Task.project))
+ .filter(
+ Task.due_date.isnot(None),
+ Task.due_date <= today_date,
+ Task.status.notin_(["done", "cancelled"]),
+ )
+ .order_by(Task.due_date.asc(), Task.priority.desc())
+ )
+ if not current_user.is_admin:
+ tasks_due_query = tasks_due_query.filter(
+ or_(Task.assigned_to == current_user.id, Task.created_by == current_user.id)
+ )
+ tasks_due_today = tasks_due_query.limit(5).all()
+
+ # Tasks and milestones due in the next 7 days (excluding today/overdue)
+ upcoming_deadline_end = today_date + timedelta(days=7)
+ upcoming_tasks_query = (
+ Task.query.options(joinedload(Task.project))
+ .filter(
+ Task.due_date.isnot(None),
+ Task.due_date > today_date,
+ Task.due_date <= upcoming_deadline_end,
+ Task.status.notin_(["done", "cancelled"]),
+ )
+ .order_by(Task.due_date.asc(), Task.priority.desc())
+ )
+ if not current_user.is_admin:
+ upcoming_tasks_query = upcoming_tasks_query.filter(
+ or_(Task.assigned_to == current_user.id, Task.created_by == current_user.id)
+ )
+ upcoming_tasks = upcoming_tasks_query.limit(8).all()
+
+ from app.utils.scope_filter import get_allowed_project_ids
+
+ scope_project_ids = get_allowed_project_ids(current_user)
+ upcoming_milestones_query = (
+ Milestone.query.options(joinedload(Milestone.project))
+ .filter(
+ Milestone.due_date.isnot(None),
+ Milestone.due_date > today_date,
+ Milestone.due_date <= upcoming_deadline_end,
+ Milestone.status != "completed",
+ )
+ .order_by(Milestone.due_date.asc())
+ )
+ if scope_project_ids is not None:
+ upcoming_milestones_query = upcoming_milestones_query.filter(Milestone.project_id.in_(scope_project_ids))
+ upcoming_milestones = upcoming_milestones_query.limit(8).all()
+
+ upcoming_deadlines = []
+ for task in upcoming_tasks:
+ upcoming_deadlines.append(
+ {
+ "kind": "task",
+ "name": task.name,
+ "due_date": task.due_date,
+ "project_name": task.project.name if task.project else None,
+ "url": url_for("tasks.view_task", task_id=task.id),
+ }
+ )
+ for milestone in upcoming_milestones:
+ upcoming_deadlines.append(
+ {
+ "kind": "milestone",
+ "name": milestone.name,
+ "due_date": milestone.due_date,
+ "project_name": milestone.project.name if milestone.project else None,
+ "url": url_for("projects.view_project", project_id=milestone.project_id),
+ }
+ )
+ upcoming_deadlines.sort(key=lambda item: item["due_date"])
+ upcoming_deadlines = upcoming_deadlines[:10]
+
+ # Overdue invoice summary for billing users
+ overdue_invoices_summary = None
+ from app.utils.module_helpers import is_module_enabled
+
+ if is_module_enabled("invoices") and (
+ current_user.is_admin or current_user.has_permission("create_invoices")
+ ):
+ overdue_rows = (
+ Invoice.query.filter(
+ Invoice.status.in_(["sent", "overdue", "partially_paid"]),
+ Invoice.due_date < today_date,
+ )
+ .order_by(Invoice.due_date.asc())
+ .all()
+ )
+ overdue_total = 0.0
+ overdue_count = 0
+ for inv in overdue_rows:
+ outstanding = float(inv.outstanding_amount or 0)
+ if outstanding > 0:
+ overdue_count += 1
+ overdue_total += outstanding
+ if overdue_count:
+ overdue_invoices_summary = {
+ "count": overdue_count,
+ "total": round(overdue_total, 2),
+ "url": url_for("invoices.list_invoices", status="overdue"),
+ }
+
# Post-timer toast data (show "Logged Xh on Project" + link to time entries)
timer_stopped_toast = session.pop("timer_stopped_toast", None)
if timer_stopped_toast:
@@ -264,22 +412,44 @@ def dashboard():
today_hours=float(today_hours or 0),
)
if support_dashboard_prompt:
- SupportPromptService.mark_prompt_shown(session, support_dashboard_prompt["variant"])
v = support_dashboard_prompt.get("variant")
+ if v == SupportPromptService.VARIANT_HOURS_MILESTONE:
+ SupportPromptService.mark_hours_milestone_shown(
+ session, int(support_dashboard_prompt.get("milestone") or 0)
+ )
+ else:
+ SupportPromptService.mark_prompt_shown(session, v)
if v == SupportPromptService.VARIANT_SEVEN_DAY:
support_dashboard_prompt = {
**support_dashboard_prompt,
"message": _(
- "You have been using TimeTracker for a week or more. If it fits your workflow, "
- "consider supporting continued development."
+ "A week in — glad you're here. TimeTracker is built by one person, "
+ "and every bit of support helps."
+ ),
+ }
+ elif v == SupportPromptService.VARIANT_ANNIVERSARY_30D:
+ support_dashboard_prompt = {
+ **support_dashboard_prompt,
+ "message": _(
+ "You've been using TimeTracker for a month — thank you for being part of the community. "
+ "If the app helps your work, consider supporting its development."
+ ),
+ }
+ elif v == SupportPromptService.VARIANT_HOURS_MILESTONE:
+ milestone = int(support_dashboard_prompt.get("milestone") or 0)
+ support_dashboard_prompt = {
+ **support_dashboard_prompt,
+ "message": _(
+ "You've tracked %(hours)s hours with TimeTracker. That's reliable data for your clients "
+ "and your business — consider supporting continued development.",
+ hours=milestone,
),
}
elif v == SupportPromptService.VARIANT_ACTIVE_TODAY:
support_dashboard_prompt = {
**support_dashboard_prompt,
"message": _(
- "You have tracked a solid amount of time today. If TimeTracker makes your day easier, "
- "you can support the project in a click."
+ "You've been tracking for a while today. TimeTracker is free because of supporters like you."
),
}
@@ -321,7 +491,16 @@ def dashboard():
"templates": templates,
"recent_activities": recent_activities,
"last_timer_context": last_timer_context,
+ "recent_combos": recent_combos,
+ "today_seconds": today_seconds,
+ "daily_target_seconds": daily_target_seconds,
+ "week_utilization": week_utilization,
+ "is_past_midday": is_past_midday,
"recent_tags": recent_tags,
+ "tasks_due_today": tasks_due_today,
+ "upcoming_deadlines": upcoming_deadlines,
+ "overdue_invoices_summary": overdue_invoices_summary,
+ "today_date": today_date,
"user_stats": user_stats, # For smart banner
"time_entries_count": time_entries_count, # For donation widget
"total_hours": total_hours, # For donation widget
@@ -344,6 +523,7 @@ def productivity_dashboard():
summary = ProductivityService.get_summary(current_user)
daily_breakdown = ProductivityService.get_daily_breakdown(current_user, days=14)
+ daily_project_breakdown = ProductivityService.get_daily_project_breakdown(current_user, days=14)
streak = ProductivityService.get_streak(current_user)
focus = ProductivityService.get_focus_stats(current_user, days=30)
projects = ProductivityService.get_project_breakdown(current_user, days=30)
@@ -356,6 +536,7 @@ def productivity_dashboard():
"main/productivity_dashboard.html",
summary=summary,
daily_breakdown=daily_breakdown,
+ daily_project_breakdown=daily_project_breakdown,
streak=streak,
focus=focus,
projects=projects,
diff --git a/app/routes/payments.py b/app/routes/payments.py
index 6d05c42f..694967d5 100644
--- a/app/routes/payments.py
+++ b/app/routes/payments.py
@@ -1,7 +1,7 @@
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
-from flask import Blueprint, flash, jsonify, redirect, render_template, request, send_file, url_for
+from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, send_file, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from sqlalchemy import and_, func, or_
@@ -272,7 +272,16 @@ def create_payment():
update_stock=True,
)
except Exception as e:
- pass # Don't fail payment creation on stock errors
+ current_app.logger.warning(
+ "Payment saved but inventory stock update failed for invoice %s: %s",
+ invoice.id,
+ e,
+ exc_info=True,
+ )
+ flash(
+ _("Payment saved, but inventory could not be updated. Please verify stock levels."),
+ "warning",
+ )
if not safe_commit("create_payment", {"invoice_id": invoice_id, "amount": float(amount)}):
flash(_("Could not create payment due to a database error. Please check server logs."), "error")
diff --git a/app/routes/recurring_tasks.py b/app/routes/recurring_tasks.py
index 8f3e4187..8a468c99 100644
--- a/app/routes/recurring_tasks.py
+++ b/app/routes/recurring_tasks.py
@@ -9,26 +9,94 @@
from flask_login import current_user, login_required
from app import db
-from app.models import Project
+from app.models import Project, User
from app.models.recurring_task import RecurringTask
+from app.utils.db import safe_commit
from app.utils.module_helpers import module_enabled
recurring_tasks_bp = Blueprint("recurring_tasks", __name__)
+def _can_manage_recurring_task(recurring_task):
+ return current_user.is_admin or recurring_task.created_by == current_user.id
+
+
+def _active_projects():
+ return Project.query.filter_by(status="active").order_by(Project.name).all()
+
+
+def _active_users():
+ return User.query.filter_by(is_active=True).order_by(User.username).all()
+
+
+def _parse_recurring_task_form(data):
+ """Parse recurring task fields from form or JSON payload."""
+ end_date_raw = data.get("end_date")
+ estimated_raw = data.get("estimated_hours")
+ assigned_raw = data.get("assigned_to")
+
+ return {
+ "name": (data.get("name") or "").strip(),
+ "project_id": int(data.get("project_id")),
+ "frequency": data.get("frequency"),
+ "next_run_date": datetime.strptime(data.get("next_run_date"), "%Y-%m-%d").date(),
+ "interval": int(data.get("interval", 1) or 1),
+ "end_date": datetime.strptime(end_date_raw, "%Y-%m-%d").date() if end_date_raw else None,
+ "task_name_template": (data.get("task_name_template") or data.get("name") or "").strip(),
+ "description": data.get("description"),
+ "priority": data.get("priority", "medium"),
+ "estimated_hours": float(estimated_raw) if estimated_raw else None,
+ "assigned_to": int(assigned_raw) if assigned_raw else None,
+ "auto_assign": bool(data.get("auto_assign", False)),
+ }
+
+
+def _apply_recurring_task_fields(recurring_task, fields):
+ recurring_task.name = fields["name"]
+ recurring_task.project_id = fields["project_id"]
+ recurring_task.frequency = fields["frequency"]
+ recurring_task.next_run_date = fields["next_run_date"]
+ recurring_task.interval = fields["interval"]
+ recurring_task.end_date = fields["end_date"]
+ recurring_task.task_name_template = fields["task_name_template"]
+ recurring_task.description = fields["description"]
+ recurring_task.priority = fields["priority"]
+ recurring_task.estimated_hours = fields["estimated_hours"]
+ recurring_task.assigned_to = fields["assigned_to"]
+ recurring_task.auto_assign = fields["auto_assign"]
+
+
@recurring_tasks_bp.route("/recurring-tasks")
@login_required
@module_enabled("recurring_tasks")
def list_recurring_tasks():
"""List all recurring tasks"""
+ status_filter = request.args.get("status", "all")
+ project_filter = request.args.get("project_id", type=int)
+
if current_user.is_admin:
- recurring_tasks = RecurringTask.query.order_by(RecurringTask.next_run_date.asc()).all()
+ query = RecurringTask.query
else:
- recurring_tasks = (
- RecurringTask.query.filter_by(created_by=current_user.id).order_by(RecurringTask.next_run_date.asc()).all()
- )
+ query = RecurringTask.query.filter_by(created_by=current_user.id)
+
+ if status_filter == "active":
+ query = query.filter_by(is_active=True)
+ elif status_filter == "inactive":
+ query = query.filter_by(is_active=False)
- return render_template("recurring_tasks/list.html", recurring_tasks=recurring_tasks)
+ if project_filter:
+ query = query.filter_by(project_id=project_filter)
+
+ recurring_tasks = query.order_by(RecurringTask.next_run_date.asc()).all()
+ projects = _active_projects()
+
+ return render_template(
+ "recurring_tasks/list.html",
+ recurring_tasks=recurring_tasks,
+ projects=projects,
+ status_filter=status_filter,
+ project_filter=project_filter,
+ )
@recurring_tasks_bp.route("/recurring-tasks/create", methods=["GET", "POST"])
@@ -38,25 +106,34 @@ def create_recurring_task():
"""Create a new recurring task"""
if request.method == "POST":
data = request.get_json() if request.is_json else request.form
+ fields = _parse_recurring_task_form(data)
recurring_task = RecurringTask(
- name=data.get("name"),
- project_id=int(data.get("project_id")),
- frequency=data.get("frequency"),
- next_run_date=datetime.strptime(data.get("next_run_date"), "%Y-%m-%d").date(),
+ name=fields["name"],
+ project_id=fields["project_id"],
+ frequency=fields["frequency"],
+ next_run_date=fields["next_run_date"],
created_by=current_user.id,
- interval=int(data.get("interval", 1)),
- end_date=datetime.strptime(data.get("end_date"), "%Y-%m-%d").date() if data.get("end_date") else None,
- task_name_template=data.get("task_name_template", data.get("name")),
- description=data.get("description"),
- priority=data.get("priority", "medium"),
- estimated_hours=float(data.get("estimated_hours")) if data.get("estimated_hours") else None,
- assigned_to=int(data.get("assigned_to")) if data.get("assigned_to") else None,
- auto_assign=bool(data.get("auto_assign", False)),
+ interval=fields["interval"],
+ end_date=fields["end_date"],
+ task_name_template=fields["task_name_template"],
+ description=fields["description"],
+ priority=fields["priority"],
+ estimated_hours=fields["estimated_hours"],
+ assigned_to=fields["assigned_to"],
+ auto_assign=fields["auto_assign"],
)
db.session.add(recurring_task)
- db.session.commit()
+ if not safe_commit("create_recurring_task"):
+ if request.is_json:
+ return jsonify({"error": "Database error"}), 500
+ flash(_("Could not create recurring task due to a database error."), "error")
+ return render_template(
+ "recurring_tasks/create.html",
+ projects=_active_projects(),
+ users=_active_users(),
+ )
if request.is_json:
return jsonify({"success": True, "recurring_task": recurring_task.to_dict()})
@@ -64,10 +141,11 @@ def create_recurring_task():
flash(_("Recurring task created successfully"), "success")
return redirect(url_for("recurring_tasks.list_recurring_tasks"))
- # GET - Show form
- projects = Project.query.filter_by(status="active").order_by(Project.name).all()
-
- return render_template("recurring_tasks/create.html", projects=projects)
+ return render_template(
+ "recurring_tasks/create.html",
+ projects=_active_projects(),
+ users=_active_users(),
+ )
@recurring_tasks_bp.route("/recurring-tasks/")
@@ -77,13 +155,75 @@ def view_recurring_task(task_id):
"""View recurring task details"""
recurring_task = RecurringTask.query.get_or_404(task_id)
- if recurring_task.created_by != current_user.id and not current_user.is_admin:
+ if not _can_manage_recurring_task(recurring_task):
flash(_("Access denied"), "error")
return redirect(url_for("recurring_tasks.list_recurring_tasks"))
return render_template("recurring_tasks/view.html", recurring_task=recurring_task)
+@recurring_tasks_bp.route("/recurring-tasks//edit", methods=["GET", "POST"])
+@login_required
+@module_enabled("recurring_tasks")
+def edit_recurring_task(task_id):
+ """Edit a recurring task"""
+ recurring_task = RecurringTask.query.get_or_404(task_id)
+
+ if not _can_manage_recurring_task(recurring_task):
+ flash(_("Access denied"), "error")
+ return redirect(url_for("recurring_tasks.list_recurring_tasks"))
+
+ if request.method == "POST":
+ data = request.get_json() if request.is_json else request.form
+ fields = _parse_recurring_task_form(data)
+ _apply_recurring_task_fields(recurring_task, fields)
+
+ if not safe_commit("edit_recurring_task", {"task_id": task_id}):
+ if request.is_json:
+ return jsonify({"error": "Database error"}), 500
+ flash(_("Could not update recurring task due to a database error."), "error")
+ return render_template(
+ "recurring_tasks/edit.html",
+ recurring_task=recurring_task,
+ projects=_active_projects(),
+ users=_active_users(),
+ )
+
+ if request.is_json:
+ return jsonify({"success": True, "recurring_task": recurring_task.to_dict()})
+
+ flash(_("Recurring task updated successfully"), "success")
+ return redirect(url_for("recurring_tasks.view_recurring_task", task_id=recurring_task.id))
+
+ return render_template(
+ "recurring_tasks/edit.html",
+ recurring_task=recurring_task,
+ projects=_active_projects(),
+ users=_active_users(),
+ )
+
+
+@recurring_tasks_bp.route("/recurring-tasks//delete", methods=["POST"])
+@login_required
+@module_enabled("recurring_tasks")
+def delete_recurring_task(task_id):
+ """Delete a recurring task"""
+ recurring_task = RecurringTask.query.get_or_404(task_id)
+
+ if not _can_manage_recurring_task(recurring_task):
+ flash(_("Access denied"), "error")
+ return redirect(url_for("recurring_tasks.list_recurring_tasks"))
+
+ name = recurring_task.name
+ db.session.delete(recurring_task)
+ if not safe_commit("delete_recurring_task", {"task_id": task_id}):
+ flash(_("Could not delete recurring task due to a database error."), "error")
+ return redirect(url_for("recurring_tasks.list_recurring_tasks"))
+
+ flash(_("Recurring task \"%(name)s\" deleted successfully", name=name), "success")
+ return redirect(url_for("recurring_tasks.list_recurring_tasks"))
+
+
@recurring_tasks_bp.route("/recurring-tasks//toggle", methods=["POST"])
@login_required
@module_enabled("recurring_tasks")
@@ -91,10 +231,42 @@ def toggle_recurring_task(task_id):
"""Toggle recurring task active status"""
recurring_task = RecurringTask.query.get_or_404(task_id)
- if recurring_task.created_by != current_user.id and not current_user.is_admin:
+ if not _can_manage_recurring_task(recurring_task):
return jsonify({"error": "Access denied"}), 403
recurring_task.is_active = not recurring_task.is_active
- db.session.commit()
+ if not safe_commit("toggle_recurring_task", {"task_id": task_id}):
+ return jsonify({"error": "Database error"}), 500
return jsonify({"success": True, "is_active": recurring_task.is_active})
+
+
+@recurring_tasks_bp.route("/recurring-tasks//run-now", methods=["POST"])
+@login_required
+@module_enabled("recurring_tasks")
+def run_recurring_task_now(task_id):
+ """Immediately create a task from this recurring template"""
+ recurring_task = RecurringTask.query.get_or_404(task_id)
+
+ if not _can_manage_recurring_task(recurring_task):
+ return jsonify({"error": "Access denied"}), 403
+
+ if not recurring_task.is_active:
+ return jsonify({"error": _("Recurring task is inactive")}), 400
+
+ if recurring_task.end_date and recurring_task.next_run_date > recurring_task.end_date:
+ return jsonify({"error": _("Recurring task has passed its end date")}), 400
+
+ try:
+ task = recurring_task.create_task()
+ return jsonify(
+ {
+ "success": True,
+ "task": task.to_dict() if hasattr(task, "to_dict") else {"id": task.id, "name": task.name},
+ "last_created_at": recurring_task.last_created_at.isoformat() if recurring_task.last_created_at else None,
+ "next_run_date": recurring_task.next_run_date.isoformat() if recurring_task.next_run_date else None,
+ }
+ )
+ except Exception:
+ db.session.rollback()
+ return jsonify({"error": _("Could not create task")}), 500
diff --git a/app/routes/timer.py b/app/routes/timer.py
index 2b2c0182..f18700f4 100644
--- a/app/routes/timer.py
+++ b/app/routes/timer.py
@@ -794,6 +794,7 @@ def timer_status():
"timer": {
"id": active_timer.id,
"project_name": active_timer.project.name if active_timer.project else None,
+ "project_id": active_timer.project_id,
"client_name": active_timer.client.name if active_timer.client else None,
"start_time": active_timer.start_time.isoformat(),
"current_duration": active_timer.current_duration_seconds,
@@ -807,6 +808,84 @@ def timer_status():
)
+@timer_bp.route("/timer/switch-project", methods=["POST"])
+@login_required
+def switch_timer_project():
+ """Switch the active timer to a different project without stopping."""
+ active_timer = current_user.active_timer
+ if not active_timer:
+ if request.headers.get("Accept", "").find("application/json") >= 0 or request.is_json:
+ return jsonify({"success": False, "error": "no_active_timer"}), 400
+ flash(_("No active timer to switch"), "error")
+ return redirect(url_for("main.dashboard"))
+
+ payload = request.get_json(silent=True) or {}
+ new_project_id = _parse_optional_int(payload.get("project_id") or request.form.get("project_id"))
+ if not new_project_id:
+ return jsonify({"success": False, "error": "missing_project_id"}), 400
+
+ if new_project_id == active_timer.project_id:
+ return jsonify({"success": True, "timer": {"project_id": new_project_id}})
+
+ from app.utils.scope_filter import user_can_access_project
+
+ if not user_can_access_project(current_user, new_project_id):
+ return jsonify({"success": False, "error": "access_denied"}), 403
+
+ new_project = Project.query.filter_by(id=new_project_id, status="active").first()
+ if not new_project:
+ return jsonify({"success": False, "error": "invalid_project"}), 400
+
+ from app.services import TimeTrackingService
+
+ result = TimeTrackingService().update_entry(
+ entry_id=active_timer.id,
+ user_id=current_user.id,
+ is_admin=current_user.is_admin,
+ project_id=new_project_id,
+ task_id=None,
+ reason="Switched project from floating timer",
+ )
+ if not result.get("success"):
+ return jsonify({"success": False, "error": result.get("error", "update_failed")}), 400
+
+ try:
+ from app.utils.cache import invalidate_dashboard_for_user
+
+ invalidate_dashboard_for_user(current_user.id)
+ except Exception as e:
+ safe_log(current_app.logger, "debug", "Dashboard cache invalidation failed: %s", e)
+
+ return jsonify(
+ {
+ "success": True,
+ "timer": {
+ "id": active_timer.id,
+ "project_id": new_project_id,
+ "project_name": new_project.name,
+ },
+ }
+ )
+
+
+@timer_bp.route("/timer/projects")
+@login_required
+def timer_projects():
+ """Active projects for quick timer switch UI."""
+ from app.utils.scope_filter import apply_project_scope_to_model
+
+ projects_query = Project.query.filter_by(status="active").order_by(Project.name)
+ scope_p = apply_project_scope_to_model(Project, current_user)
+ if scope_p is not None:
+ projects_query = projects_query.filter(scope_p)
+ projects = projects_query.all()
+ return jsonify(
+ {
+ "projects": [{"id": p.id, "name": p.name} for p in projects],
+ }
+ )
+
+
@timer_bp.route("/timer/edit/", methods=["GET", "POST"])
@login_required
def edit_timer(timer_id):
diff --git a/app/routes/workflows.py b/app/routes/workflows.py
index 91714295..629e994c 100644
--- a/app/routes/workflows.py
+++ b/app/routes/workflows.py
@@ -5,6 +5,7 @@
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
+from sqlalchemy import func
from app import db
from app.models.workflow import WorkflowExecution, WorkflowRule, WorkflowTemplate
@@ -39,7 +40,34 @@ def list_workflows():
.all()
)
- return render_template("workflows/list.html", workflows=workflows)
+ workflow_ids = [w.id for w in workflows]
+ last_executions = {}
+ if workflow_ids:
+ subq = (
+ db.session.query(
+ WorkflowExecution.rule_id,
+ func.max(WorkflowExecution.executed_at).label("max_executed_at"),
+ )
+ .filter(WorkflowExecution.rule_id.in_(workflow_ids))
+ .group_by(WorkflowExecution.rule_id)
+ .subquery()
+ )
+ rows = (
+ db.session.query(WorkflowExecution)
+ .join(
+ subq,
+ (WorkflowExecution.rule_id == subq.c.rule_id)
+ & (WorkflowExecution.executed_at == subq.c.max_executed_at),
+ )
+ .all()
+ )
+ last_executions = {row.rule_id: row for row in rows}
+
+ return render_template(
+ "workflows/list.html",
+ workflows=workflows,
+ last_executions=last_executions,
+ )
@workflows_bp.route("/workflows/create", methods=["GET", "POST"])
diff --git a/app/services/invoice_service.py b/app/services/invoice_service.py
index 3caf0bf6..eea5632e 100644
--- a/app/services/invoice_service.py
+++ b/app/services/invoice_service.py
@@ -22,6 +22,61 @@ def __init__(self):
self.invoice_repo = InvoiceRepository()
self.project_repo = ProjectRepository()
+ @staticmethod
+ def _resolve_currency_for_project(project) -> str:
+ """Resolve invoice currency: client custom field → system settings → EUR."""
+ from app.models import Settings
+
+ client = getattr(project, "client_obj", None)
+ if client and getattr(client, "custom_fields", None):
+ currency = (client.custom_fields or {}).get("currency")
+ if currency:
+ return str(currency).upper()[:3]
+ settings = Settings.get_settings()
+ if settings and getattr(settings, "currency", None):
+ return settings.currency
+ return "EUR"
+
+ @staticmethod
+ def _resolve_tax_rate_for_project(project, issue_date: Optional[date] = None) -> Decimal:
+ """Resolve tax rate: TaxRule (project → client) → project custom field → client custom field → 0."""
+ from app.models import Settings
+ from app.models.tax_rule import TaxRule
+
+ today = issue_date or date.today()
+ query = TaxRule.query.filter(TaxRule.active == True)
+ query = query.filter(
+ (TaxRule.start_date.is_(None) | (TaxRule.start_date <= today)),
+ (TaxRule.end_date.is_(None) | (TaxRule.end_date >= today)),
+ )
+ candidates = []
+ if project and getattr(project, "id", None):
+ candidates = query.filter(TaxRule.project_id == project.id).all()
+ if not candidates and project and getattr(project, "client_id", None):
+ candidates = query.filter(TaxRule.client_id == project.client_id).all()
+ if candidates:
+ candidates.sort(key=lambda r: float(r.rate_percent), reverse=True)
+ return Decimal(str(candidates[0].rate_percent))
+
+ if project and getattr(project, "custom_fields", None):
+ project_tax = (project.custom_fields or {}).get("tax_rate")
+ if project_tax is not None and str(project_tax).strip() != "":
+ return Decimal(str(project_tax))
+
+ client = getattr(project, "client_obj", None) if project else None
+ if client and getattr(client, "custom_fields", None):
+ client_tax = (client.custom_fields or {}).get("default_tax_rate") or (client.custom_fields or {}).get(
+ "tax_rate"
+ )
+ if client_tax is not None and str(client_tax).strip() != "":
+ return Decimal(str(client_tax))
+
+ settings = Settings.get_settings()
+ if settings and getattr(settings, "company_tax_id", None):
+ # No default tax rate on settings model; fall through to zero.
+ pass
+ return Decimal("0.00")
+
def create_invoice_from_time_entries(
self,
project_id: int,
@@ -62,8 +117,10 @@ def create_invoice_from_time_entries(
rate = project.hourly_rate or Decimal("0.00")
subtotal += hours * rate
- # Get tax rate (from project or default)
- tax_rate = Decimal("0.00") # Should come from project/client settings
+ # Resolve tax rate and currency from project/client/settings
+ resolved_issue_date = issue_date or date.today()
+ tax_rate = self._resolve_tax_rate_for_project(project, resolved_issue_date)
+ currency_code = self._resolve_currency_for_project(project)
tax_amount = subtotal * (tax_rate / 100)
total_amount = subtotal + tax_amount
@@ -74,14 +131,14 @@ def create_invoice_from_time_entries(
client_id=project.client_id,
# Project.client is a string property; relationship is Project.client_obj
client_name=(project.client_obj.name if getattr(project, "client_obj", None) else project.client) or "",
- issue_date=issue_date or date.today(),
+ issue_date=resolved_issue_date,
due_date=due_date or date.today(),
status=InvoiceStatus.DRAFT.value,
subtotal=subtotal,
tax_rate=tax_rate,
tax_amount=tax_amount,
total_amount=total_amount,
- currency_code="EUR", # Should come from project/client
+ currency_code=currency_code,
created_by=created_by,
)
@@ -109,6 +166,13 @@ def create_invoice_from_time_entries(
grouped_entries = items # for telemetry line_item_count
+ if include_expenses:
+ from app.models import Expense
+
+ uninvoiced_expenses = Expense.get_uninvoiced_expenses(project_id=project_id)
+ for expense in uninvoiced_expenses:
+ expense.mark_as_invoiced(invoice.id)
+
# Derive subtotal/tax/total from the persisted line items. Invoice.__init__
# ignores subtotal/tax_amount/total_amount kwargs, so the values passed to
# invoice_repo.create() above never stick; recompute from the items (whose
@@ -242,6 +306,20 @@ def create_invoice(
def mark_as_sent(self, invoice_id: int) -> Dict[str, Any]:
"""Mark an invoice as sent and mark associated time entries as paid"""
+ from app.models import Invoice
+
+ invoice_before = self.invoice_repo.get_by_id(invoice_id)
+ sent_statuses = ("sent", "paid", "overdue", "issued")
+ was_first_send = bool(
+ invoice_before
+ and invoice_before.status == "draft"
+ and Invoice.query.filter(
+ Invoice.created_by == invoice_before.created_by,
+ Invoice.status.in_(sent_statuses),
+ ).count()
+ == 0
+ )
+
invoice = self.invoice_repo.mark_as_sent(invoice_id)
if not invoice:
@@ -261,6 +339,20 @@ def mark_as_sent(self, invoice_id: int) -> Dict[str, Any]:
if marked_count > 0:
message += f" ({marked_count} time entr{'y' if marked_count == 1 else 'ies'} marked as paid)"
+ try:
+ from app.utils.support_invoice_sent import queue_first_invoice_support_prompt
+
+ queue_first_invoice_support_prompt(invoice.created_by, first_send=was_first_send)
+ except Exception as exc:
+ from flask import current_app
+
+ current_app.logger.warning(
+ "Invoice marked sent but support prompt queue failed for invoice %s: %s",
+ invoice.id,
+ exc,
+ exc_info=True,
+ )
+
return {"success": True, "message": message, "invoice": invoice}
def mark_as_paid(
@@ -840,8 +932,15 @@ def create_client_unbilled_invoice(self, client_id: int, acting_user_id: int) ->
primary = Contact.get_primary_contact(client_id)
if primary and primary.email:
client_email = primary.email
- except Exception:
- pass
+ except Exception as exc:
+ from flask import current_app
+
+ current_app.logger.warning(
+ "Could not resolve primary contact email for client %s: %s",
+ client_id,
+ exc,
+ exc_info=True,
+ )
tax_rate = Decimal("0")
notes = settings.invoice_notes if settings and settings.invoice_notes else None
diff --git a/app/services/mention_service.py b/app/services/mention_service.py
index ebce4d61..dcc85224 100644
--- a/app/services/mention_service.py
+++ b/app/services/mention_service.py
@@ -130,8 +130,15 @@ def _deliver_web_push(user, note):
subscriptions = PushSubscription.get_user_subscriptions(user.id)
if subscriptions:
_deliver_push_to_subscriptions(user, subscriptions, note)
- except Exception:
- pass
+ except Exception as exc:
+ from flask import current_app
+
+ current_app.logger.warning(
+ "Failed to deliver mention web push to user %s: %s",
+ user.id,
+ exc,
+ exc_info=True,
+ )
def _emit_socket(user, note):
@@ -140,5 +147,12 @@ def _emit_socket(user, note):
from app import socketio
socketio.emit("user_mentioned", note, room=f"user_{user.id}")
- except Exception:
- pass
+ except Exception as exc:
+ from flask import current_app
+
+ current_app.logger.warning(
+ "Failed to emit mention socket event to user %s: %s",
+ user.id,
+ exc,
+ exc_info=True,
+ )
diff --git a/app/services/productivity_service.py b/app/services/productivity_service.py
index 70b9649a..bacad7a1 100644
--- a/app/services/productivity_service.py
+++ b/app/services/productivity_service.py
@@ -338,6 +338,94 @@ def get_daily_breakdown(cls, user, days: int = 14) -> List[Dict[str, Any]]:
logger.exception("ProductivityService.get_daily_breakdown failed for user %s", uid)
return []
+ @classmethod
+ def get_daily_project_breakdown(cls, user, days: int = 14) -> Dict[str, Any]:
+ """Stacked daily hours by project for charting (last N days, oldest first)."""
+ empty: Dict[str, Any] = {"dates": [], "projects": [], "hours_by_day_project": {}}
+ try:
+ days = max(1, min(int(days), 90))
+ except (TypeError, ValueError):
+ days = 14
+
+ uid = _safe_user_id(user)
+ if uid is None:
+ return empty
+
+ try:
+ today = _user_today(user)
+ start_day = today - timedelta(days=days - 1)
+ start_dt, end_dt = _user_period_bounds_app_naive(user, start_day, today)
+ user_tz = get_timezone_for_user(user)
+ app_tz = get_timezone_obj()
+
+ rows = (
+ db.session.query(
+ TimeEntry.start_time,
+ TimeEntry.duration_seconds,
+ TimeEntry.project_id,
+ Project.name,
+ )
+ .outerjoin(Project, TimeEntry.project_id == Project.id)
+ .filter(
+ TimeEntry.user_id == uid,
+ TimeEntry.end_time.isnot(None),
+ TimeEntry.start_time >= start_dt,
+ TimeEntry.start_time < end_dt,
+ )
+ .all()
+ )
+
+ by_day_project: Dict[str, Dict[str, float]] = defaultdict(lambda: defaultdict(float))
+ project_meta: Dict[str, Dict[str, Any]] = {}
+
+ for start_time, duration_seconds, project_id, project_name in rows:
+ local_dt = _to_user_local(start_time, user_tz, app_tz)
+ if local_dt is None:
+ continue
+ day_key = local_dt.date().isoformat()
+ pid = str(project_id or 0)
+ label = (project_name or "No project").strip()
+ hours = (int(duration_seconds or 0)) / 3600.0
+ by_day_project[day_key][pid] += hours
+ if pid not in project_meta:
+ palette_idx = (int(project_id or 0)) % len(_PROJECT_PALETTE)
+ project_meta[pid] = {
+ "id": project_id,
+ "name": label,
+ "color": _PROJECT_PALETTE[palette_idx],
+ }
+
+ dates: List[str] = []
+ hours_by_day_project: Dict[str, Dict[str, float]] = {}
+ cur = start_day
+ while cur <= today:
+ day_key = cur.isoformat()
+ dates.append(day_key)
+ day_map = by_day_project.get(day_key, {})
+ hours_by_day_project[day_key] = {
+ pid: round(h, 2) for pid, h in day_map.items() if h > 0
+ }
+ cur += timedelta(days=1)
+
+ # Top projects by total hours in window (limit legend/chart clutter)
+ totals: Dict[str, float] = defaultdict(float)
+ for day_map in hours_by_day_project.values():
+ for pid, h in day_map.items():
+ totals[pid] += h
+ top_pids = [
+ pid for pid, _ in sorted(totals.items(), key=lambda item: item[1], reverse=True)[:8]
+ ]
+ projects = [project_meta[pid] for pid in top_pids if pid in project_meta]
+
+ return {
+ "dates": dates,
+ "projects": projects,
+ "hours_by_day_project": hours_by_day_project,
+ }
+ except Exception:
+ logger.exception("ProductivityService.get_daily_project_breakdown failed for user %s", uid)
+ return empty
+
# ---------------------------------------------------------------- streak
@classmethod
diff --git a/app/services/support_prompt_service.py b/app/services/support_prompt_service.py
index 817f05ee..5e9c6e3b 100644
--- a/app/services/support_prompt_service.py
+++ b/app/services/support_prompt_service.py
@@ -12,12 +12,20 @@ class SupportPromptService:
SESSION_PROMPT_TRIGGER = "support_prompt_trigger"
SESSION_SEVEN_DAY_OFFERED = "support_prompt_7d_offered"
SESSION_ACTIVE_DAY_OFFERED = "support_prompt_active_day_offered"
+ SESSION_ANNIVERSARY_30D_OFFERED = "support_prompt_30d_offered"
+ SESSION_HOURS_MILESTONE_OFFERED = "support_prompt_hours_milestone_offered"
VARIANT_AFTER_REPORT = "after_report"
+ VARIANT_FIRST_INVOICE = "first_invoice"
VARIANT_SEVEN_DAY = "seven_day"
+ VARIANT_ANNIVERSARY_30D = "anniversary_30d"
+ VARIANT_HOURS_MILESTONE = "hours_milestone"
VARIANT_ACTIVE_TODAY = "active_today"
VARIANT_LONG_SESSION = "long_session"
+ LAYOUT_TRIGGER_VARIANTS = frozenset({VARIANT_AFTER_REPORT, VARIANT_FIRST_INVOICE})
+ HOURS_MILESTONES = (1000, 500, 100)
+
@staticmethod
def _base_eligible(
session: Dict[str, Any],
@@ -56,11 +64,12 @@ def consume_layout_prompt(
):
return None
trigger = session.get(SupportPromptService.SESSION_PROMPT_TRIGGER)
- if trigger != SupportPromptService.VARIANT_AFTER_REPORT:
+ if trigger not in SupportPromptService.LAYOUT_TRIGGER_VARIANTS:
return None
session.pop(SupportPromptService.SESSION_PROMPT_TRIGGER, None)
session[SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED] = True
- return {"variant": SupportPromptService.VARIANT_AFTER_REPORT, "source": "after_report"}
+ source = "after_report" if trigger == SupportPromptService.VARIANT_AFTER_REPORT else "first_invoice"
+ return {"variant": trigger, "source": source}
@staticmethod
def pick_dashboard_prompt(
@@ -83,11 +92,24 @@ def pick_dashboard_prompt(
support_banner_suppressed=support_banner_suppressed,
):
return None
- # After-report takes priority; leave trigger for layout pass
- if session.get(SupportPromptService.SESSION_PROMPT_TRIGGER) == SupportPromptService.VARIANT_AFTER_REPORT:
+ # Layout triggers take priority; leave trigger for layout pass
+ if session.get(SupportPromptService.SESSION_PROMPT_TRIGGER) in SupportPromptService.LAYOUT_TRIGGER_VARIANTS:
return None
+ total_hours = float(user_stats.get("total_hours") or 0)
+ offered_milestone = int(session.get(SupportPromptService.SESSION_HOURS_MILESTONE_OFFERED) or 0)
+ for milestone in SupportPromptService.HOURS_MILESTONES:
+ if total_hours >= milestone and offered_milestone < milestone:
+ return {
+ "variant": SupportPromptService.VARIANT_HOURS_MILESTONE,
+ "source": "dashboard",
+ "milestone": milestone,
+ }
+
days = int(user_stats.get("days_since_signup") or 0)
+ if days >= 30 and not session.get(SupportPromptService.SESSION_ANNIVERSARY_30D_OFFERED):
+ return {"variant": SupportPromptService.VARIANT_ANNIVERSARY_30D, "source": "dashboard"}
+
if days >= 7 and not session.get(SupportPromptService.SESSION_SEVEN_DAY_OFFERED):
return {"variant": SupportPromptService.VARIANT_SEVEN_DAY, "source": "dashboard"}
@@ -101,11 +123,18 @@ def mark_prompt_shown(session: Dict[str, Any], variant: str) -> None:
session[SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED] = True
if variant == SupportPromptService.VARIANT_SEVEN_DAY:
session[SupportPromptService.SESSION_SEVEN_DAY_OFFERED] = True
+ elif variant == SupportPromptService.VARIANT_ANNIVERSARY_30D:
+ session[SupportPromptService.SESSION_ANNIVERSARY_30D_OFFERED] = True
elif variant == SupportPromptService.VARIANT_ACTIVE_TODAY:
session[SupportPromptService.SESSION_ACTIVE_DAY_OFFERED] = True
elif variant == SupportPromptService.VARIANT_LONG_SESSION:
pass
+ @staticmethod
+ def mark_hours_milestone_shown(session: Dict[str, Any], milestone: int) -> None:
+ session[SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED] = True
+ session[SupportPromptService.SESSION_HOURS_MILESTONE_OFFERED] = int(milestone)
+
@staticmethod
def long_session_prompt_allowed(
session: Dict[str, Any],
@@ -122,6 +151,13 @@ def long_session_prompt_allowed(
support_banner_suppressed=support_banner_suppressed,
):
return False
- if session.get(SupportPromptService.SESSION_PROMPT_TRIGGER) == SupportPromptService.VARIANT_AFTER_REPORT:
+ if session.get(SupportPromptService.SESSION_PROMPT_TRIGGER) in SupportPromptService.LAYOUT_TRIGGER_VARIANTS:
return False
return True
+
+ @staticmethod
+ def queue_first_invoice_prompt(session: Dict[str, Any], user_id: int, *, first_send: bool = False) -> None:
+ """Queue a one-shot support prompt after the user's first invoice is sent."""
+ if not first_send:
+ return
+ session[SupportPromptService.SESSION_PROMPT_TRIGGER] = SupportPromptService.VARIANT_FIRST_INVOICE
diff --git a/app/static/floating-timer-bar.js b/app/static/floating-timer-bar.js
index 2f4946da..61fd361e 100644
--- a/app/static/floating-timer-bar.js
+++ b/app/static/floating-timer-bar.js
@@ -1,6 +1,6 @@
/**
* Floating Timer Bar - Persistent mini-timer visible on all pages
- * One-click start/stop without navigating to dashboard
+ * One-click start/stop without navigating away from the current page when possible
*/
(function () {
'use strict';
@@ -24,6 +24,11 @@
this.startTime = null;
this.startLabel = 'Start Timer';
this.stopLabel = 'Stop';
+ this.pauseLabel = 'Pause';
+ this.resumeLabel = 'Resume';
+ this.switchLabel = 'Switch project';
+ this.projectsCache = null;
+ this.switchPopover = null;
this.init();
}
@@ -32,10 +37,117 @@
this.bar = document.getElementById('floatingTimerBar');
this.startLabel = this.bar.dataset.startLabel || 'Start Timer';
this.stopLabel = this.bar.dataset.stopLabel || 'Stop';
+ this.pauseLabel = this.bar.dataset.pauseLabel || 'Pause';
+ this.resumeLabel = this.bar.dataset.resumeLabel || 'Resume';
+ this.switchLabel = this.bar.dataset.switchLabel || 'Switch project';
+ this.ensureSwitchPopover();
this.render();
this.fetchStatus();
this.pollTimer = setInterval(() => this.fetchStatus(), POLL_INTERVAL_MS);
window.addEventListener('focus', () => this.fetchStatus());
+ document.addEventListener('click', (evt) => {
+ if (!this.switchPopover || this.switchPopover.classList.contains('hidden')) return;
+ if (this.switchPopover.contains(evt.target)) return;
+ if (this.bar && this.bar.contains(evt.target)) return;
+ this.hideSwitchPopover();
+ });
+ }
+
+ ensureSwitchPopover() {
+ if (this.switchPopover) return;
+ var pop = document.createElement('div');
+ pop.id = 'floatingTimerSwitchPopover';
+ pop.className = 'hidden fixed z-[70] w-72 max-w-[calc(100vw-1rem)] rounded-xl border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark shadow-xl p-3';
+ pop.innerHTML =
+ '' + escapeHtml(this.switchLabel) + '
' +
+ ' ' +
+ '' +
+ 'Cancel ' +
+ 'Apply ' +
+ '
';
+ document.body.appendChild(pop);
+ pop.querySelector('[data-switch-cancel]').addEventListener('click', () => this.hideSwitchPopover());
+ pop.querySelector('[data-switch-apply]').addEventListener('click', () => this.applySwitchProject());
+ this.switchPopover = pop;
+ }
+
+ hideSwitchPopover() {
+ if (this.switchPopover) this.switchPopover.classList.add('hidden');
+ }
+
+ positionSwitchPopover() {
+ if (!this.switchPopover || !this.bar) return;
+ var rect = this.bar.getBoundingClientRect();
+ var top = Math.max(8, rect.bottom + 8);
+ var left = Math.min(window.innerWidth - this.switchPopover.offsetWidth - 8, Math.max(8, rect.right - this.switchPopover.offsetWidth));
+ this.switchPopover.style.top = top + 'px';
+ this.switchPopover.style.left = left + 'px';
+ }
+
+ async loadProjects() {
+ if (this.projectsCache) return this.projectsCache;
+ try {
+ const res = await fetch('/timer/projects', { credentials: 'same-origin' });
+ const data = await res.json();
+ this.projectsCache = Array.isArray(data.projects) ? data.projects : [];
+ } catch (e) {
+ this.projectsCache = [];
+ }
+ return this.projectsCache;
+ }
+
+ async openSwitchPopover() {
+ this.ensureSwitchPopover();
+ const select = this.switchPopover.querySelector('[data-switch-project-select]');
+ select.innerHTML = 'Loading… ';
+ this.switchPopover.classList.remove('hidden');
+ this.positionSwitchPopover();
+
+ const projects = await this.loadProjects();
+ select.innerHTML = '';
+ projects.forEach((p) => {
+ const opt = document.createElement('option');
+ opt.value = String(p.id);
+ opt.textContent = p.name;
+ if (this.timerData && this.timerData.project_id === p.id) {
+ opt.selected = true;
+ }
+ select.appendChild(opt);
+ });
+ if (!projects.length) {
+ select.innerHTML = '' + escapeHtml('No projects') + ' ';
+ }
+ }
+
+ async applySwitchProject() {
+ const select = this.switchPopover.querySelector('[data-switch-project-select]');
+ const projectId = select ? parseInt(select.value, 10) : NaN;
+ if (!projectId) return;
+ const token = this.getCsrfToken();
+ try {
+ const res = await fetch('/timer/switch-project', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': token,
+ 'Accept': 'application/json',
+ },
+ body: JSON.stringify({ project_id: projectId }),
+ credentials: 'same-origin',
+ });
+ const data = await res.json();
+ if (!res.ok || !data.success) {
+ throw new Error(data.error || 'switch_failed');
+ }
+ this.hideSwitchPopover();
+ await this.fetchStatus();
+ this.refreshDashboardTimerWidget();
+ } catch (e) {
+ console.error('Switch project failed', e);
+ if (window.toastManager) {
+ window.toastManager.error('Could not switch project', 'Error', 3000);
+ }
+ }
}
async fetchStatus() {
@@ -51,8 +163,10 @@
this.timerData = null;
this.startTime = null;
this.stopElapsedUpdater();
+ this.hideSwitchPopover();
this.render();
}
+ syncFabDesktopHide(this.timerData);
} catch (e) {
console.warn('FloatingTimerBar: fetch status failed', e);
}
@@ -76,9 +190,8 @@
const formatted = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
const el = this.bar.querySelector('[data-timer-elapsed]');
if (el) el.textContent = formatted;
- const btn = this.bar.querySelector('button');
- const label = this.timerData.paused ? (this.bar.dataset.resumeLabel || 'Resume') : (this.stopLabel || 'Stop');
- if (btn) btn.title = (this.getLabel() || 'Timer') + (this.timerData.paused ? ' (Paused) – ' : ' – ') + formatted + ' – ' + label;
+ const progressEl = this.bar.querySelector('[data-timer-progress]');
+ if (progressEl) progressEl.style.width = this.getProgressPercent(elapsedSec) + '%';
};
update();
this.elapsedInterval = setInterval(update, 1000);
@@ -95,54 +208,50 @@
const startBtn = document.querySelector('#openStartTimer');
if (startBtn) {
startBtn.click();
- } else {
- const url = this.bar?.dataset?.manualUrl || '/timer/manual';
- window.location.href = url;
+ return;
}
+ const dashboardUrl = this.bar?.dataset?.dashboardUrl || '/';
+ window.location.href = dashboardUrl + (dashboardUrl.indexOf('#') >= 0 ? '' : '#start-timer');
}
- async stopTimer() {
+ async postTimerAction(url) {
const token = this.getCsrfToken();
try {
- const res = await fetch('/timer/stop', {
+ await fetch(url, {
method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': token },
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRFToken': token,
+ 'Accept': 'application/json',
+ },
body: 'csrf_token=' + encodeURIComponent(token),
- credentials: 'same-origin'
+ credentials: 'same-origin',
+ redirect: 'manual',
});
- if (res.redirected) {
- window.location.href = res.url;
- } else {
- await this.fetchStatus();
- }
+ await this.fetchStatus();
+ this.refreshDashboardTimerWidget();
} catch (e) {
- console.error('Stop timer failed', e);
+ console.error('Timer action failed:', url, e);
if (window.toastManager) {
- window.toastManager.error('Failed to stop timer', 'Error', 3000);
+ window.toastManager.error('Timer action failed', 'Error', 3000);
}
}
}
- async resumeTimer() {
- const token = this.getCsrfToken();
- try {
- const res = await fetch('/timer/resume', {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': token },
- body: 'csrf_token=' + encodeURIComponent(token),
- credentials: 'same-origin'
- });
- if (res.redirected) {
- window.location.href = res.url;
- } else {
- await this.fetchStatus();
- }
- } catch (e) {
- console.error('Resume timer failed', e);
- if (window.toastManager) {
- window.toastManager.error('Failed to resume timer', 'Error', 3000);
- }
- }
+ stopTimer() {
+ return this.postTimerAction('/timer/stop');
+ }
+
+ pauseTimer() {
+ return this.postTimerAction('/timer/pause');
+ }
+
+ resumeTimer() {
+ return this.postTimerAction('/timer/resume');
+ }
+
+ refreshDashboardTimerWidget() {
+ document.dispatchEvent(new CustomEvent('tt:timer-status-changed'));
}
getCsrfToken() {
@@ -155,30 +264,68 @@
return this.timerData.project_name || this.timerData.client_name || 'Timer';
}
+ getDailyTargetSeconds() {
+ const raw = this.bar && this.bar.dataset.dailyTargetHours;
+ const hours = raw ? parseFloat(raw, 10) : 8;
+ const safeHours = isNaN(hours) || hours <= 0 ? 8 : hours;
+ return Math.round(safeHours * 3600);
+ }
+
+ getProgressPercent(elapsedSec) {
+ const target = this.getDailyTargetSeconds();
+ if (!target) return 0;
+ return Math.min(100, Math.round((elapsedSec / target) * 100));
+ }
+
render() {
if (!this.bar) return;
- const baseClass = 'floating-timer-bar__round flex items-center justify-center w-10 h-10 rounded-full text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 text-sm transition-colors';
- const actionLabel = this.timerData && this.timerData.paused ? (this.bar.dataset.resumeLabel || 'Resume') : (this.stopLabel || 'Stop');
- const title = this.timerData
- ? (escapeHtml(this.getLabel()) + (this.timerData.paused ? ' (Paused) – ' : ' – ') + (this.timerData.duration_formatted || '00:00:00') + ' – ' + escapeHtml(actionLabel))
- : escapeHtml(this.startLabel);
+ const roundBtn = 'floating-timer-bar__round flex items-center justify-center w-8 h-8 rounded-full text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/50 text-sm transition-colors shrink-0';
if (this.timerData) {
const isPaused = this.timerData.paused;
const pulseClass = isPaused ? 'bg-amber-500' : 'bg-green-500 animate-pulse';
- const clickHandler = isPaused ? 'window.floatingTimerBar.resumeTimer()' : 'window.floatingTimerBar.stopTimer()';
+ const elapsedSec = this.timerData.current_duration != null
+ ? this.timerData.current_duration
+ : (this.startTime ? Math.floor((Date.now() - this.startTime) / 1000) : 0);
+ const progressPct = this.getProgressPercent(elapsedSec);
+ const label = escapeHtml(this.getLabel());
+ const elapsed = this.timerData.duration_formatted || '00:00:00';
+ const pauseResumeLabel = isPaused ? this.resumeLabel : this.pauseLabel;
+ const pauseResumeIcon = isPaused ? 'play' : 'pause';
+ const pauseResumeAction = isPaused ? 'resumeTimer' : 'pauseTimer';
+
+ this.bar.className = 'flex shrink-0 items-center gap-1 floating-timer-bar--active';
this.bar.innerHTML = `
-
-
-
- ${this.timerData.duration_formatted || '00:00:00'}
-
+
+
+
${elapsed}
+
+
+
+
+
+
+
+
+
+
+
`;
+ const switchBtn = this.bar.querySelector('[data-switch-project-btn]');
+ if (switchBtn) {
+ switchBtn.addEventListener('click', (evt) => {
+ evt.stopPropagation();
+ this.openSwitchPopover();
+ });
+ }
this.startElapsedUpdater();
} else {
+ this.bar.className = 'flex shrink-0 items-center justify-center';
this.bar.innerHTML = `
-
+
`;
@@ -189,6 +336,10 @@
destroy() {
this.stopElapsedUpdater();
if (this.pollTimer) clearInterval(this.pollTimer);
+ if (this.switchPopover) {
+ this.switchPopover.remove();
+ this.switchPopover = null;
+ }
}
}
@@ -201,6 +352,7 @@
const style = document.createElement('style');
style.textContent = `
.floating-timer-bar__round { cursor: pointer; }
+ .floating-timer-bar--active { max-width: 13rem; }
@media (prefers-reduced-motion: reduce) {
.floating-timer-bar__round .animate-pulse { animation: none; }
}
diff --git a/app/static/idle.js b/app/static/idle.js
index 05b6e400..63be8169 100644
--- a/app/static/idle.js
+++ b/app/static/idle.js
@@ -73,6 +73,32 @@
if (countdownIntervalId) { clearInterval(countdownIntervalId); countdownIntervalId = null; }
}
+ async function refreshTimerUiAfterStop(){
+ try {
+ if (window.floatingTimerBar && typeof window.floatingTimerBar.fetchStatus === 'function') {
+ await window.floatingTimerBar.fetchStatus();
+ }
+ document.dispatchEvent(new CustomEvent('tt:timer-status-changed'));
+ } catch(e) {}
+ }
+
+ async function stopNow(){
+ clearGraceTimers();
+ promptShown = false;
+ try {
+ const r = await fetch('/api/timer/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, __ttQuiet: true });
+ if (r.ok){
+ const msg = window.i18n?.messages?.timerStopped || 'Timer stopped';
+ if (window.toastManager && window.toastManager.success) {
+ window.toastManager.success(msg, '', 5000);
+ } else if (window.toastManager && window.toastManager.show) {
+ window.toastManager.show({ message: msg, type: 'success', duration: 5000 });
+ }
+ await refreshTimerUiAfterStop();
+ }
+ } catch(e) {}
+ }
+
async function stopAt(ts){
clearGraceTimers();
promptShown = false;
@@ -87,7 +113,7 @@
} else {
alert(msg);
}
- location.reload();
+ await refreshTimerUiAfterStop();
}
} catch(e) {}
}
@@ -107,6 +133,7 @@
const yesLabel = window.i18n?.messages?.stillWorkingYes || 'Yes, still working';
const noLabel = window.i18n?.messages?.stillWorkingNo || 'No, stop timer';
+ const trimLabel = window.i18n?.messages?.stillWorkingTrim || 'Keep until idle';
const baseMsg = window.i18n?.messages?.stillWorkingPrompt ||
('Still working? You seem inactive since ' + formatTime(new Date(stopTs)) +
'. Timer will stop automatically if you do not answer.');
@@ -120,8 +147,10 @@
function attachHandlers(toastEl, countdownEl){
const yesBtn = toastEl.querySelector('[data-act="yes"]');
const noBtn = toastEl.querySelector('[data-act="no"]');
+ const trimBtn = toastEl.querySelector('[data-act="trim"]');
if (yesBtn) yesBtn.addEventListener('click', function(){ snoozeIdlePrompt(toastEl); });
- if (noBtn) noBtn.addEventListener('click', function(){ try { toastEl.remove(); } catch(e){}; stopAt(stopTs); });
+ if (noBtn) noBtn.addEventListener('click', function(){ try { toastEl.remove(); } catch(e){}; stopNow(); });
+ if (trimBtn) trimBtn.addEventListener('click', function(){ try { toastEl.remove(); } catch(e){}; stopAt(stopTs); });
countdownIntervalId = setInterval(function(){
if (countdownEl) countdownEl.textContent = buildMessage();
@@ -139,8 +168,9 @@
toastEl.className = 'flex items-center gap-3 p-4 bg-amber-100 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded-lg shadow-lg pointer-events-auto';
toastEl.innerHTML =
'' + buildMessage() + '
' +
- '' +
+ '
' +
'' + yesLabel + ' ' +
+ '' + trimLabel + ' ' +
'' + noLabel + ' ' +
'
';
const container = document.getElementById('toast-notification-container') || document.getElementById('flash-messages-container') || document.body;
@@ -156,6 +186,7 @@
'
' + buildMessage() + '
' +
'
' +
'' + yesLabel + ' ' +
+ '' + trimLabel + ' ' +
'' + noLabel + ' ' +
'
' +
'
';
@@ -164,16 +195,61 @@
attachHandlers(t, t.querySelector('[data-countdown]'));
}
+ let longEntryNudgeShown = false;
+ let lastLongEntryTimerId = null;
+
+ function getLongEntryThresholdMs(){
+ const meta = document.querySelector('meta[name="long-entry-threshold-hours"]');
+ const hours = meta ? parseFloat(meta.getAttribute('content'), 10) : 8;
+ return (isNaN(hours) || hours < 1 ? 8 : Math.min(24, hours)) * 60 * 60 * 1000;
+ }
+
+ async function checkLongRunningTimer(active){
+ if (!active || !active.start_time) return;
+ if (active.id !== lastLongEntryTimerId) {
+ lastLongEntryTimerId = active.id;
+ longEntryNudgeShown = false;
+ }
+ if (longEntryNudgeShown || activeReminderToast) return;
+ const started = new Date(active.start_time).getTime();
+ if (isNaN(started)) return;
+ const runningMs = Date.now() - started;
+ if (runningMs < getLongEntryThresholdMs()) return;
+ longEntryNudgeShown = true;
+ const hours = (runningMs / (60 * 60 * 1000)).toFixed(1);
+ const msg = 'Your timer has been running for ' + hours + ' hours. Did you forget to stop it?';
+ buildReminderToast(
+ 'amber',
+ escapeHtml(msg),
+ [
+ {
+ label: (window.i18n?.messages?.timerStopped || 'Stop timer'),
+ style: 'primary',
+ onClick: function(){ window.floatingTimerBar && window.floatingTimerBar.stopTimer(); }
+ },
+ {
+ label: (window.i18n?.messages?.dismiss || 'Dismiss'),
+ style: 'link',
+ onClick: function(){ longEntryNudgeShown = true; }
+ }
+ ],
+ 0
+ );
+ }
+
async function tick(){
const active = await getTimer();
hasActiveTimer = !!active;
if (!active) return;
+ // Send periodic heartbeat while tab is open (throttled to HEARTBEAT_THROTTLE_MS)
+ if (!promptShown) sendHeartbeat();
const threshold = getIdleThresholdMs();
const idleFor = Date.now() - lastActivity;
if (idleFor >= threshold){
const stopTs = Date.now() - idleFor;
showIdlePrompt(stopTs);
}
+ try { await checkLongRunningTimer(active); } catch(e) {}
// Break reminder follows the active timer state; check on every tick.
try { checkBreakNudge(active); } catch(e) {}
}
@@ -382,8 +458,10 @@
label: (window.i18n?.messages?.pauseTimer || 'Pause timer'),
style: 'primary',
onClick: async function(){
- try { await fetch('/timer/pause', { method: 'POST' }); } catch(e){}
- location.reload();
+ try {
+ await fetch('/timer/pause', { method: 'POST', redirect: 'manual', credentials: 'same-origin' });
+ await refreshTimerUiAfterStop();
+ } catch(e){}
}
},
{
diff --git a/app/static/images/apple-touch-icon.png b/app/static/images/apple-touch-icon.png
new file mode 100644
index 00000000..7d222e9b
Binary files /dev/null and b/app/static/images/apple-touch-icon.png differ
diff --git a/app/static/images/favicon.ico b/app/static/images/favicon.ico
new file mode 100644
index 00000000..70b570b3
Binary files /dev/null and b/app/static/images/favicon.ico differ
diff --git a/app/static/images/og-image.png b/app/static/images/og-image.png
new file mode 100644
index 00000000..c1aefb78
Binary files /dev/null and b/app/static/images/og-image.png differ
diff --git a/app/static/notes-autocomplete.js b/app/static/notes-autocomplete.js
new file mode 100644
index 00000000..a2735349
--- /dev/null
+++ b/app/static/notes-autocomplete.js
@@ -0,0 +1,104 @@
+/**
+ * Populate description/notes autocomplete from recent time entries.
+ * Targets #notes with optional #notesSuggestions datalist, scoped by #project_id.
+ */
+(function () {
+ 'use strict';
+
+ if (window.__ttNotesAutocompleteLoaded) return;
+ window.__ttNotesAutocompleteLoaded = true;
+
+ function getProjectId() {
+ var projectSelect = document.getElementById('project_id');
+ return projectSelect && projectSelect.value ? projectSelect.value : '';
+ }
+
+ function ensureDatalist() {
+ var datalist = document.getElementById('notesSuggestions');
+ if (!datalist) {
+ datalist = document.createElement('datalist');
+ datalist.id = 'notesSuggestions';
+ document.body.appendChild(datalist);
+ }
+ return datalist;
+ }
+
+ function ensureChipContainer() {
+ var host = document.getElementById('notes_editor');
+ var anchor = host ? host.parentElement : document.getElementById('notes');
+ if (!anchor) return null;
+ var container = document.getElementById('notesSuggestionChips');
+ if (!container) {
+ container = document.createElement('div');
+ container.id = 'notesSuggestionChips';
+ container.className = 'mt-2 flex flex-wrap gap-1.5';
+ anchor.insertAdjacentElement('afterend', container);
+ }
+ return container;
+ }
+
+ function applyNotesValue(value) {
+ var notesInput = document.getElementById('notes');
+ if (notesInput) notesInput.value = value || '';
+ if (window.mdEditor && typeof window.mdEditor.setMarkdown === 'function') {
+ try { window.mdEditor.setMarkdown(value || ''); } catch (_) {}
+ }
+ }
+
+ function renderChips(suggestions) {
+ var container = ensureChipContainer();
+ if (!container) return;
+ container.innerHTML = '';
+ if (!suggestions.length) return;
+ suggestions.slice(0, 8).forEach(function (text) {
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'px-2 py-1 rounded-md text-xs bg-background-light dark:bg-background-dark border border-border-light dark:border-border-dark text-text-light dark:text-text-dark hover:border-primary';
+ btn.textContent = text.length > 48 ? text.slice(0, 47) + '…' : text;
+ btn.title = text;
+ btn.addEventListener('click', function () { applyNotesValue(text); });
+ container.appendChild(btn);
+ });
+ }
+
+ function populateDatalist(suggestions) {
+ var notesInput = document.getElementById('notes');
+ if (!notesInput) return;
+ var datalist = ensureDatalist();
+ notesInput.setAttribute('list', 'notesSuggestions');
+ datalist.innerHTML = '';
+ suggestions.forEach(function (text) {
+ var opt = document.createElement('option');
+ opt.value = text;
+ datalist.appendChild(opt);
+ });
+ }
+
+ var loadTimer = null;
+ function scheduleLoad() {
+ if (loadTimer) clearTimeout(loadTimer);
+ loadTimer = setTimeout(loadSuggestions, 200);
+ }
+
+ function loadSuggestions() {
+ var projectId = getProjectId();
+ var url = '/api/timer/notes-suggestions' + (projectId ? ('?project_id=' + encodeURIComponent(projectId)) : '');
+ fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (data) {
+ var suggestions = (data && data.suggestions) || [];
+ populateDatalist(suggestions);
+ renderChips(suggestions);
+ })
+ .catch(function () {});
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ if (!document.getElementById('notes')) return;
+ var projectSelect = document.getElementById('project_id');
+ if (projectSelect) {
+ projectSelect.addEventListener('change', scheduleLoad);
+ }
+ loadSuggestions();
+ });
+})();
diff --git a/app/static/support-ui.js b/app/static/support-ui.js
index beeda289..e20021ff 100644
--- a/app/static/support-ui.js
+++ b/app/static/support-ui.js
@@ -148,7 +148,7 @@
if (!data || !data.show) return;
var msg =
(cfg.i18n && cfg.i18n.longSessionToast) ||
- 'If TimeTracker helps your day, consider supporting its development.';
+ "You've been tracking for a while today. TimeTracker is free because of supporters like you.";
var act = (cfg.i18n && cfg.i18n.supportAction) || 'Support';
if (window.toastManager && typeof window.toastManager.show === 'function') {
window.toastManager.show({
diff --git a/app/templates/admin/client_portal_customization.html b/app/templates/admin/client_portal_customization.html
new file mode 100644
index 00000000..f6f23478
--- /dev/null
+++ b/app/templates/admin/client_portal_customization.html
@@ -0,0 +1,136 @@
+{% extends "base.html" %}
+{% from "components/ui.html" import page_header %}
+
+{% block title %}{{ _('Portal Customization') }} - {{ client.name }} - {{ app_name }}{% endblock %}
+
+{% block content %}
+{% set breadcrumbs = [
+ {'text': _('Clients'), 'url': url_for('clients.list_clients')},
+ {'text': client.name, 'url': url_for('clients.view_client', client_id=client.id)},
+ {'text': _('Portal Customization')}
+] %}
+
+{{ page_header(
+ icon_class='fas fa-palette',
+ title_text=_('Portal Customization'),
+ subtitle_text=client.name,
+ breadcrumbs=breadcrumbs
+) }}
+
+
+{% endblock %}
diff --git a/app/templates/base.html b/app/templates/base.html
index f5119a82..15a0268c 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -70,14 +70,14 @@
{{ _('Enjoying TimeTracker?') }}
- {{ _('Support independent development — licenses are supporter badges, not paywalls.') }}
+ {{ _('Become a Supporter or buy a coffee — every contribution helps keep TimeTracker free for everyone.') }}
- {{ _('Support / Get key') }}
+ {{ _('Become a Supporter') }}
{{ _('PayPal') }}
- {{ _('Support / License') }}
+ {{ _('Support options') }}
+
{% endif %}
{% if is_admin_user %}
diff --git a/app/templates/components/support_modal.html b/app/templates/components/support_modal.html
index eb07f915..7ef20beb 100644
--- a/app/templates/components/support_modal.html
+++ b/app/templates/components/support_modal.html
@@ -46,14 +46,14 @@ {{ _('Support TimeTrack
{% if not is_license_activated %}
- {{ _('A license is a supporter badge — it does not lock features. You keep full access either way.') }}
+ {{ _('Becoming a Supporter is a thank-you badge — it does not lock features. Support prompts are minimized as a side effect.') }}
{% endif %}
diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html
index 078495f6..1ad14a7b 100644
--- a/app/templates/main/dashboard.html
+++ b/app/templates/main/dashboard.html
@@ -38,6 +38,18 @@
{% endif %}
+{% if overdue_invoices_summary %}
+
+
+ {{ _('Overdue invoices') }}
+
+
+ {{ _('%(count)s invoice(s) overdue — %(total)s outstanding', count=overdue_invoices_summary.count, total=overdue_invoices_summary.total|format_currency) }}
+
+
{{ _('Review invoices') }}
+
+{% endif %}
+
@@ -419,8 +512,67 @@
{{ _('This