Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c1daf62
fix(timer): keep heartbeats alive and stop at idle boundary (#736)
evilguy4000 Aug 23, 2026
c1825e5
Improve support incentives with Supporter identity and smarter prompts.
evilguy4000 Aug 23, 2026
8189ac9
fix(recurring-tasks): complete CRUD flow and add sidebar navigation
evilguy4000 Aug 23, 2026
5c7dded
fix(portal): add missing client portal customization admin template
evilguy4000 Aug 23, 2026
c6ec6d5
chore(static): add missing favicon, apple touch icon, and OG image
evilguy4000 Aug 23, 2026
d2add63
feat(mobile): expand bottom nav More drawer with key destinations
evilguy4000 Aug 23, 2026
646bec3
feat(dashboard): add daily progress widget and quick-start timer strip
evilguy4000 Aug 23, 2026
2dc62c3
feat(timer): warn on long-running entries and show workday progress
evilguy4000 Aug 23, 2026
f19a92a
fix: log swallowed errors and surface user feedback where actionable
evilguy4000 Aug 23, 2026
02aed66
Improve floating timer bar and idle-stop UX.
evilguy4000 Aug 23, 2026
5111a36
Add dashboard quick log time and tasks due today widgets.
evilguy4000 Aug 23, 2026
c7b49f5
Enhance recurring tasks list with filters, toggles, and run-now.
evilguy4000 Aug 23, 2026
1bfc77d
Declutter navigation and improve mobile More drawer.
evilguy4000 Aug 23, 2026
0f0dada
Resolve invoice tax rate and currency from project/client settings.
evilguy4000 Aug 23, 2026
5d3de5f
Fix productivity heatmap and empty_state template errors.
evilguy4000 Aug 23, 2026
4eb7e11
Add backend support for dashboard alerts, timer switch, and productiv…
evilguy4000 Aug 23, 2026
e7b3aae
Improve UX across dashboard, lists, timer bar, and productivity views.
evilguy4000 Aug 23, 2026
b7a5a71
chore(release): bump version to 5.13.0 and update docs
evilguy4000 Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
36 changes: 36 additions & 0 deletions app/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion app/routes/api_v1_mileage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions app/routes/expenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
195 changes: 188 additions & 7 deletions app/routes/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."
),
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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,
Expand Down
Loading
Loading