From c1daf62f09257ba2324f18dfc3504a922877f79c Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 15:00:09 +0200 Subject: [PATCH 01/18] fix(timer): keep heartbeats alive and stop at idle boundary (#736) Send periodic heartbeats from the idle tick loop so open tabs keep timers alive without requiring mouse/keyboard activity, and backdate idle auto-stops to the idle-timeout boundary instead of start_time when no heartbeat exists. Fixes #736 --- app/static/idle.js | 2 ++ app/utils/scheduled_tasks.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/static/idle.js b/app/static/idle.js index 05b6e400..69307fea 100644 --- a/app/static/idle.js +++ b/app/static/idle.js @@ -168,6 +168,8 @@ 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){ diff --git a/app/utils/scheduled_tasks.py b/app/utils/scheduled_tasks.py index 3a027c44..9ea636be 100644 --- a/app/utils/scheduled_tasks.py +++ b/app/utils/scheduled_tasks.py @@ -955,7 +955,7 @@ def check_idle_timers(): if getattr(notified_at, "tzinfo", None) is not None: notified_at = notified_at.replace(tzinfo=None) if (now - notified_at) >= grace: - stop_at = entry.last_heartbeat_at or entry.start_time + stop_at = entry.last_heartbeat_at or (now - threshold) if getattr(stop_at, "tzinfo", None) is not None: stop_at = stop_at.replace(tzinfo=None) if stop_at and stop_at > now: From c1825e5fc17b826574b21d8415f3ab912b600f75 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 15:46:22 +0200 Subject: [PATCH 02/18] Improve support incentives with Supporter identity and smarter prompts. Reframe licensing as becoming a Supporter rather than removing prompts, add visible badges, more personal donate messaging, and contextual nudges for first invoice, usage milestones, and 30-day anniversaries. --- app/routes/main.py | 32 ++++++-- app/services/invoice_service.py | 21 ++++++ app/services/support_prompt_service.py | 46 +++++++++-- app/static/support-ui.js | 2 +- app/templates/base.html | 6 +- app/templates/components/support_modal.html | 4 +- app/templates/main/donate.html | 84 ++++++++++++++------- app/templates/partials/_sidebar.html | 9 ++- app/templates/partials/_topbar.html | 2 +- app/templates/user/license.html | 35 +++++---- app/templates/user/settings.html | 21 ++++-- app/utils/context_processors.py | 12 ++- app/utils/email.py | 19 ++++- app/utils/support_invoice_sent.py | 12 +++ tests/test_support_services.py | 44 +++++++++++ 15 files changed, 279 insertions(+), 70 deletions(-) create mode 100644 app/utils/support_invoice_sent.py diff --git a/app/routes/main.py b/app/routes/main.py index 50a596d6..489765d7 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -264,22 +264,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." ), } diff --git a/app/services/invoice_service.py b/app/services/invoice_service.py index 3caf0bf6..fbd5cc0c 100644 --- a/app/services/invoice_service.py +++ b/app/services/invoice_service.py @@ -242,6 +242,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 +275,13 @@ 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: + pass + return {"success": True, "message": message, "invoice": invoice} def mark_as_paid( 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/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/base.html b/app/templates/base.html index f5119a82..5ba20759 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') }}
{% 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/donate.html b/app/templates/main/donate.html index e942ff96..03ffb73e 100644 --- a/app/templates/main/donate.html +++ b/app/templates/main/donate.html @@ -17,7 +17,7 @@

{{ _('Support TimeTracker Development') }}

{% if (support_ab_variant|default('control')) == 'cta_alt' %} - {{ _('Donate to support development — or get a key to remove prompts') }} + {{ _('Donate to support development — or become a Supporter to show your badge') }} {% else %} {{ _('Support updates and keep TimeTracker free for everyone') }} {% endif %} @@ -26,7 +26,7 @@

{{ _('Support TimeTracker Develo {% if (support_ab_variant|default('control')) == 'key_first' %} - {{ _('Remove prompts with key') }} + {{ _('Become a Supporter') }} {{ _('Support TimeTracker Develo {% endif %}

- {{ _('Remove prompts with a one-time key.') }} - {{ _('Get key') }} + {{ _('Supporters get a visible badge and fewer prompts — no features are locked.') }} + {{ _('Become a Supporter') }} +

+ + + + +
+

+ + {{ _('A note from the developer') }} +

+
+

+ {{ _('I built TimeTracker because I needed simple, reliable time tracking for my own work — without subscriptions, vendor lock-in, or features hidden behind a paywall.') }} +

+

+ {{ _('Keeping it running means real costs: hosting, databases, domains, and the time to fix bugs, ship improvements, and answer community questions. When you donate or become a Supporter, that money goes directly toward those costs — not a corporation, just one independent developer trying to build something useful.') }} +

+

+ {{ _('Thank you for using TimeTracker. Whether you contribute financially, star the repo, or spread the word — it genuinely helps.') }}

@@ -56,50 +75,50 @@

{{ _('Support TimeTracker Develo

- {{ _('Why Your Support Matters') }} + {{ _('Where your support goes') }}

- {{ _('TimeTracker is a free, open-source project built with passion and dedication. Your donations directly support:') }} + {{ _('Real numbers — every contribution, no matter the size, makes a difference:') }}

- +
-

{{ _('Server Infrastructure') }}

+

{{ _('€5 — a coffee') }}

- {{ _('Hosting, databases, and CDN costs to keep TimeTracker fast and reliable') }} + {{ _('Covers roughly a month of basic hosting for the project website and documentation') }}

- +
-

{{ _('Feature Development') }}

+

{{ _('€10 — infrastructure') }}

- {{ _('New features, improvements, and bug fixes based on your feedback') }} + {{ _('Helps pay for server resources, CDN, and database costs that keep releases and downloads available') }}

- +
-

{{ _('Security & Maintenance') }}

+

{{ _('€25 — become a Supporter') }}

- {{ _('Regular security updates, dependency maintenance, and performance optimization') }} + {{ _('Covers about a quarter of server and domain costs, plus shows a Supporter badge in the app — no features locked') }}

- +
-

{{ _('Internationalization') }}

+

{{ _('Any amount — development time') }}

- {{ _('Translation support, localization, and making TimeTracker accessible worldwide') }} + {{ _('Funds bug fixes, new features, translations, and keeping TimeTracker free for everyone') }}

@@ -107,14 +126,14 @@

{{ _('Internationalization') }}

- +

- - {{ _('Remove prompts with key') }} + + {{ _('Become a Supporter') }}

- {{ _('One key per instance; key sent by email after payment (€25 one-time). No subscription.') }} + {{ _('One-time €25 contribution per instance. You receive a key by email. Supporters get a visible badge; support prompts are minimized as a side effect. No subscription.') }}

  1. @@ -135,7 +154,7 @@

    rel="noopener noreferrer" onclick="trackDonationClick('donate_page_key')" class="inline-flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-semibold transition-all shadow-md hover:shadow-lg"> - {{ _('Get key') }} + {{ _('Support TimeTracker (€25)') }}

@@ -241,6 +260,19 @@

{{ _('Other Ways to Help') }}

+ + +
+
{{ _('Star on GitHub') }}
+
+ {{ _('Free way to help — stars increase visibility and bring more users to the project') }} +
+
+
+ rel="noopener noreferrer" onclick="trackDonationClick('donate_page_key')" class="flex items-center gap-3 p-4 bg-background-light dark:bg-background-dark rounded-lg hover:bg-background-hover-light dark:hover:bg-background-hover-dark transition-colors"> - +
-
{{ _('Remove prompts with key') }}
+
{{ _('Become a Supporter') }}
- {{ _('One-time key per instance; no subscription') }} + {{ _('One-time €25 — visible badge, fewer prompts, no subscription') }}
diff --git a/app/templates/partials/_sidebar.html b/app/templates/partials/_sidebar.html index 17013500..f0792c22 100644 --- a/app/templates/partials/_sidebar.html +++ b/app/templates/partials/_sidebar.html @@ -751,7 +751,14 @@

{{ _('Help') }} - {% if current_user.is_authenticated and current_user.ui_show_donate and not is_license_activated %} + {% if is_license_activated %} +
  • + +
  • + {% elif current_user.is_authenticated and current_user.ui_show_donate %}
  • {{ _('Need a key?') }} - {{ _('Purchase a license key') }} + {{ _('Become a Supporter') }}

  • {% endif %} diff --git a/app/templates/user/settings.html b/app/templates/user/settings.html index 847eabf8..678fad29 100644 --- a/app/templates/user/settings.html +++ b/app/templates/user/settings.html @@ -10,21 +10,26 @@

    {{ _('Settings') }}
    -

    - {{ _('Support & Community') }} +

    + {{ _('Support & Community') }} + {% if is_license_activated %} + + {{ _('Supporter') }} + + {% endif %}

    {{ _('TimeTracker is free and open source. Funding comes from optional donations and supporter licenses — never from locking features.') }}

    {% if is_license_activated %} -

    {{ _('This instance already has a supporter license. Thank you — you can still donate or share the app anytime.') }}

    +

    {{ _('Thank you for being a TimeTracker Supporter. Your badge shows across the app — you can still donate or share anytime.') }}

    {% else %} -

    {{ _('If the app saves you time, you can donate or buy a supporter license (€25). A license shows a Supporter badge; it does not change what you can use.') }}

    +

    {{ _('If the app saves you time, become a Supporter with a one-time €25 contribution. You keep full access either way — supporters get a visible badge and fewer prompts.') }}

    {% endif %}
    @@ -607,9 +612,9 @@

    - {{ _('Support visibility (hiding donate/support UI) is configured system-wide by administrators in Admin → Settings.') }} - {{ _('Administrators can purchase a key to hide these prompts:') }} - {{ _('Support & Purchase Key') }}. + {{ _('Supporter status is configured instance-wide by administrators in Admin → Settings.') }} + {{ _('Anyone can become a Supporter:') }} + {{ _('Support TimeTracker') }}.

    diff --git a/app/utils/context_processors.py b/app/utils/context_processors.py index 6d01b384..aa830eb9 100644 --- a/app/utils/context_processors.py +++ b/app/utils/context_processors.py @@ -229,8 +229,12 @@ def inject_globals(): v = layout_support_prompt.get("variant") if v == SupportPromptService.VARIANT_AFTER_REPORT: lp_message = _( - "That report was quick to generate. If TimeTracker saves you time, " - "consider supporting its development." + "Report exported — if TimeTracker saves you time, a coffee goes a long way." + ) + elif v == SupportPromptService.VARIANT_FIRST_INVOICE: + lp_message = _( + "You just sent your first invoice with TimeTracker — if the app is helping your " + "business, consider supporting its development." ) support_ui_json = json.dumps( @@ -259,8 +263,8 @@ def inject_globals(): "shareFail": _("Could not copy link"), "supportAction": _("Support"), "longSessionToast": _( - "You have been using TimeTracker actively for a while. " - "If it helps your work, consider supporting its development." + "You've been tracking for a while today. TimeTracker is free because of " + "supporters like you." ), }, }, diff --git a/app/utils/email.py b/app/utils/email.py index 16960ee1..8dc5c22a 100644 --- a/app/utils/email.py +++ b/app/utils/email.py @@ -989,11 +989,28 @@ def send_invoice_email(invoice, recipient_email, sender_user=None, custom_messag db.session.add(invoice_email) # Update invoice status to 'sent' if it's still 'draft' - if invoice.status == "draft": + was_first_send = invoice.status == "draft" + if was_first_send: invoice.status = "sent" db.session.commit() + if was_first_send: + try: + from app.models import Invoice + + sent_statuses = ("sent", "paid", "overdue", "issued") + sent_count = ( + Invoice.query.filter( + Invoice.created_by == sender_id, Invoice.status.in_(sent_statuses) + ).count() + ) + from app.utils.support_invoice_sent import queue_first_invoice_support_prompt + + queue_first_invoice_support_prompt(sender_id, first_send=sent_count == 1) + except Exception: + pass + return True, invoice_email, f"Invoice email sent successfully to {recipient_email}" except Exception as e: diff --git a/app/utils/support_invoice_sent.py b/app/utils/support_invoice_sent.py new file mode 100644 index 00000000..2fb74c4c --- /dev/null +++ b/app/utils/support_invoice_sent.py @@ -0,0 +1,12 @@ +"""Hook first invoice send for support soft prompts.""" + +from __future__ import annotations + + +def queue_first_invoice_support_prompt(user_id: int, *, first_send: bool = False) -> None: + """Queue a one-shot support prompt when the user sends their first invoice.""" + from flask import session + + from app.services.support_prompt_service import SupportPromptService + + SupportPromptService.queue_first_invoice_prompt(session, user_id, first_send=first_send) diff --git a/tests/test_support_services.py b/tests/test_support_services.py index 67dcfbf2..b6e130b1 100644 --- a/tests/test_support_services.py +++ b/tests/test_support_services.py @@ -61,3 +61,47 @@ def test_pick_dashboard_skips_when_after_report_pending(): today_hours=8.0, ) assert picked is None + + +def test_pick_dashboard_hours_milestone(): + session = {} + user_stats = {"days_since_signup": 3, "time_entries_count": 50, "total_hours": 520.0} + picked = SupportPromptService.pick_dashboard_prompt( + session, + user_stats, + ui_show_donate=True, + is_supporter=False, + support_banner_suppressed=False, + today_hours=1.0, + ) + assert picked is not None + assert picked.get("variant") == SupportPromptService.VARIANT_HOURS_MILESTONE + assert picked.get("milestone") == 500 + + +def test_pick_dashboard_anniversary_30d(): + session = {} + user_stats = {"days_since_signup": 35, "time_entries_count": 10, "total_hours": 20.0} + picked = SupportPromptService.pick_dashboard_prompt( + session, + user_stats, + ui_show_donate=True, + is_supporter=False, + support_banner_suppressed=False, + today_hours=1.0, + ) + assert picked is not None + assert picked.get("variant") == SupportPromptService.VARIANT_ANNIVERSARY_30D + + +def test_consume_layout_prompt_first_invoice(): + session = {"support_prompt_trigger": SupportPromptService.VARIANT_FIRST_INVOICE} + payload = SupportPromptService.consume_layout_prompt( + session, + ui_show_donate=True, + is_supporter=False, + support_banner_suppressed=False, + ) + assert payload is not None + assert payload.get("variant") == SupportPromptService.VARIANT_FIRST_INVOICE + assert session.get(SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED) is True From 8189ac94fd4e9e70b2364aa42d123e2f4ecb4869 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 16:04:44 +0200 Subject: [PATCH 03/18] fix(recurring-tasks): complete CRUD flow and add sidebar navigation Recurring tasks were broken because create/view templates and edit/delete routes were missing while the list page linked to them. Add the full template set, wire up edit and delete handlers, and expose the feature in the sidebar. --- app/routes/recurring_tasks.py | 160 +++++++++++++++--- app/templates/partials/_sidebar.html | 10 +- .../recurring_tasks/_form_fields.html | 113 +++++++++++++ app/templates/recurring_tasks/create.html | 20 +++ app/templates/recurring_tasks/edit.html | 21 +++ app/templates/recurring_tasks/form.html | 117 +------------ app/templates/recurring_tasks/list.html | 2 +- app/templates/recurring_tasks/view.html | 101 +++++++++++ 8 files changed, 407 insertions(+), 137 deletions(-) create mode 100644 app/templates/recurring_tasks/_form_fields.html create mode 100644 app/templates/recurring_tasks/create.html create mode 100644 app/templates/recurring_tasks/edit.html create mode 100644 app/templates/recurring_tasks/view.html diff --git a/app/routes/recurring_tasks.py b/app/routes/recurring_tasks.py index 8f3e4187..8c867350 100644 --- a/app/routes/recurring_tasks.py +++ b/app/routes/recurring_tasks.py @@ -9,13 +9,63 @@ 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") @@ -38,25 +88,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 +123,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 +137,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") diff --git a/app/templates/partials/_sidebar.html b/app/templates/partials/_sidebar.html index f0792c22..29f3e009 100644 --- a/app/templates/partials/_sidebar.html +++ b/app/templates/partials/_sidebar.html @@ -10,7 +10,7 @@

    +{% set progress_pct = (today_seconds / daily_target_seconds * 100) if daily_target_seconds and daily_target_seconds > 0 else 0 %} +{% set progress_pct_display = [progress_pct, 100]|min %} +{% if progress_pct >= 100 %} + {% set progress_color = 'emerald' %} +{% elif progress_pct >= 50 or not is_past_midday %} + {% set progress_color = 'amber' %} +{% else %} + {% set progress_color = 'red' %} +{% endif %} +{% set today_h = today_seconds // 3600 %} +{% set today_m = (today_seconds % 3600) // 60 %} + +
    +
    +
    +
    +

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

    +

    {{ _('Track toward your daily target') }}

    +
    + {% if active_timer %} + + {{ _('Timer running') }} + + {% endif %} +
    +
    +
    + + + + +
    +
    +

    + {{ today_h }}h {{ '%02d'|format(today_m) }}m + / {{ '%.0f'|format(standard_hours_per_day) }}h +

    +

    + {{ '%.0f'|format(progress_pct) }}% {{ _('of daily target') }} +

    + {% if week_utilization is defined %} +

    + {{ _('This week:') }} {{ '%.1f'|format(week_utilization.billable_hours or 0) }}h {{ _('billable') }} / {{ '%.1f'|format(week_utilization.total_hours or 0) }}h +

    + {% endif %} +
    +
    +
    + + {% if recent_combos %} +
    +

    {{ _('Quick start') }}

    +

    {{ _('Start the timer for a recent project') }}

    +
    + {% for combo in recent_combos %} +
    + + + {% if combo.task_id %}{% endif %} + +
    + {% endfor %} +
    +
    + {% endif %} +
    +
    @@ -279,6 +355,11 @@

    { {% if week_overtime_hours > 0 %}+ {{ "%.2f"|format(week_overtime_hours) }}h {{ _('overtime') }}{% endif %}

    {% endif %} + {% if week_utilization is defined and week_utilization.total_hours %} +
    + {{ '%.1f'|format(week_utilization.billable_hours or 0) }}h {{ _('billable') }} / {{ '%.1f'|format(week_utilization.total_hours or 0) }}h {{ _('this week') }} +
    + {% endif %}
    From 2dc62c3dd8b79c0f4c2e87109eb73b75dd95743e Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 16:04:48 +0200 Subject: [PATCH 08/18] feat(timer): warn on long-running entries and show workday progress Nudge users when a timer exceeds eight hours and add a daily-target progress bar to the floating timer so elapsed time has clearer workday context. --- app/static/floating-timer-bar.js | 34 +++++++++++++++++++---- app/static/idle.js | 43 +++++++++++++++++++++++++++++ app/templates/base.html | 1 + app/templates/partials/_topbar.html | 2 ++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/app/static/floating-timer-bar.js b/app/static/floating-timer-bar.js index 2f4946da..cc49f4e1 100644 --- a/app/static/floating-timer-bar.js +++ b/app/static/floating-timer-bar.js @@ -76,6 +76,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 progressEl = this.bar.querySelector('[data-timer-progress]'); + if (progressEl) progressEl.style.width = this.getProgressPercent(elapsedSec) + '%'; 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; @@ -155,6 +157,19 @@ 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; @@ -168,12 +183,21 @@ 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); this.bar.innerHTML = ` - +
    + + +
    `; this.startElapsedUpdater(); } else { diff --git a/app/static/idle.js b/app/static/idle.js index 69307fea..9ab3435b 100644 --- a/app/static/idle.js +++ b/app/static/idle.js @@ -164,6 +164,48 @@ 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; @@ -176,6 +218,7 @@ 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) {} } diff --git a/app/templates/base.html b/app/templates/base.html index 5ba20759..15a0268c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -119,6 +119,7 @@ {% if current_user.is_authenticated %} + {% endif %} {% if is_admin_user %} diff --git a/app/templates/partials/_topbar.html b/app/templates/partials/_topbar.html index ec8575bd..409891df 100644 --- a/app/templates/partials/_topbar.html +++ b/app/templates/partials/_topbar.html @@ -46,6 +46,8 @@
    {% endif %} From f19a92a5de3aa7059308fef8eefec2b737f78668 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 16:04:49 +0200 Subject: [PATCH 09/18] fix: log swallowed errors and surface user feedback where actionable Replace silent except-pass handlers in payments, mileage API, expenses, mentions, and invoicing with proper warning logs and user-visible feedback when the failure affects something the user should know about. --- app/routes/api_v1_mileage.py | 11 ++++++++++- app/routes/expenses.py | 9 +++++++-- app/routes/payments.py | 13 +++++++++++-- app/services/invoice_service.py | 22 ++++++++++++++++++---- app/services/mention_service.py | 22 ++++++++++++++++++---- 5 files changed, 64 insertions(+), 13 deletions(-) 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/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/services/invoice_service.py b/app/services/invoice_service.py index fbd5cc0c..3dc3f1b1 100644 --- a/app/services/invoice_service.py +++ b/app/services/invoice_service.py @@ -279,8 +279,15 @@ def mark_as_sent(self, invoice_id: int) -> Dict[str, Any]: 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: - pass + 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} @@ -861,8 +868,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, + ) From 02aed663aa0902aa810c58f6bfb40368400ef1ba Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 18:06:12 +0200 Subject: [PATCH 10/18] Improve floating timer bar and idle-stop UX. Show elapsed time with pause/stop controls in the topbar, start timers globally via dashboard modal, and refresh timer state softly after idle detection instead of reloading the page. --- app/static/floating-timer-bar.js | 107 +++++++++++++++------------- app/static/idle.js | 17 ++++- app/templates/partials/_topbar.html | 2 + 3 files changed, 72 insertions(+), 54 deletions(-) diff --git a/app/static/floating-timer-bar.js b/app/static/floating-timer-bar.js index cc49f4e1..4b389b02 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,8 @@ this.startTime = null; this.startLabel = 'Start Timer'; this.stopLabel = 'Stop'; + this.pauseLabel = 'Pause'; + this.resumeLabel = 'Resume'; this.init(); } @@ -32,6 +34,8 @@ 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.render(); this.fetchStatus(); this.pollTimer = setInterval(() => this.fetchStatus(), POLL_INTERVAL_MS); @@ -53,6 +57,7 @@ this.stopElapsedUpdater(); this.render(); } + syncFabDesktopHide(this.timerData); } catch (e) { console.warn('FloatingTimerBar: fetch status failed', e); } @@ -78,9 +83,6 @@ if (el) el.textContent = formatted; const progressEl = this.bar.querySelector('[data-timer-progress]'); if (progressEl) progressEl.style.width = this.getProgressPercent(elapsedSec) + '%'; - 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; }; update(); this.elapsedInterval = setInterval(update, 1000); @@ -97,54 +99,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() { @@ -173,36 +171,42 @@ 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.startElapsedUpdater(); } else { + this.bar.className = 'flex shrink-0 items-center justify-center'; this.bar.innerHTML = ` - `; @@ -225,6 +229,7 @@ const style = document.createElement('style'); style.textContent = ` .floating-timer-bar__round { cursor: pointer; } + .floating-timer-bar--active { max-width: 11rem; } @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 9ab3435b..4540458f 100644 --- a/app/static/idle.js +++ b/app/static/idle.js @@ -73,6 +73,15 @@ 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 stopAt(ts){ clearGraceTimers(); promptShown = false; @@ -87,7 +96,7 @@ } else { alert(msg); } - location.reload(); + await refreshTimerUiAfterStop(); } } catch(e) {} } @@ -427,8 +436,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/templates/partials/_topbar.html b/app/templates/partials/_topbar.html index 409891df..00535d99 100644 --- a/app/templates/partials/_topbar.html +++ b/app/templates/partials/_topbar.html @@ -46,8 +46,10 @@
    {% endif %} From 5111a36ba9097fdb0ba779a449e68040fac4deef Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 18:06:15 +0200 Subject: [PATCH 11/18] Add dashboard quick log time and tasks due today widgets. Let users log past hours without starting a timer and surface overdue or due tasks with inline completion, using existing API endpoints. --- app/routes/main.py | 24 ++- app/templates/main/dashboard.html | 277 +++++++++++++++++++++++++++++- 2 files changed, 297 insertions(+), 4 deletions(-) diff --git a/app/routes/main.py b/app/routes/main.py index c0373fe1..dd0db124 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, 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 @@ -261,6 +262,23 @@ def dashboard(): ) 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() + # 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: @@ -391,6 +409,8 @@ def dashboard(): "week_utilization": week_utilization, "is_past_midday": is_past_midday, "recent_tags": recent_tags, + "tasks_due_today": tasks_due_today, + "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 diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index 0a7ac250..4c51f2f8 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -500,8 +500,67 @@

    {{ _('This

    - -
    + +
    + +
    + + +
    +
    @@ -654,6 +713,37 @@

    {% endif %} {% endif %} + + {% if is_module_enabled('tasks') and tasks_due_today %} +
    +
    +
    +
    + +
    +

    {{ _('Tasks Due Today') }}

    +
    + {{ _('View all') }} +
    +
    +
    + {% endif %} +
    @@ -1001,6 +1091,189 @@

    {{ _('En }); })(); + // Quick Log Time card + (function initQuickLogTime() { + var toggle = document.getElementById('quickLogToggle'); + var panel = document.getElementById('quickLogPanel'); + var chevron = document.getElementById('quickLogChevron'); + var form = document.getElementById('quickLogForm'); + var projectSelect = document.getElementById('quickLogProject'); + var taskSelect = document.getElementById('quickLogTask'); + var cancelBtn = document.getElementById('quickLogCancel'); + var errorEl = document.getElementById('quickLogError'); + if (!toggle || !panel || !form) return; + + var tasksApiTemplate = {{ url_for('api.get_project_tasks', project_id=0)|tojson }}; + + function setPanelOpen(open) { + panel.classList.toggle('hidden', !open); + toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + if (chevron) chevron.classList.toggle('rotate-180', open); + } + + toggle.addEventListener('click', function() { + setPanelOpen(panel.classList.contains('hidden')); + }); + if (cancelBtn) { + cancelBtn.addEventListener('click', function() { setPanelOpen(false); }); + } + + function parseDuration(input) { + var raw = (input || '').trim().toLowerCase(); + if (!raw) return null; + var decimal = parseFloat(raw.replace(',', '.')); + if (!isNaN(decimal) && /^\d+([.,]\d+)?$/.test(raw)) { + return Math.round(decimal * 3600); + } + var totalSec = 0; + var hMatch = raw.match(/(\d+(?:[.,]\d+)?)\s*h/); + var mMatch = raw.match(/(\d+(?:[.,]\d+)?)\s*m/); + if (hMatch) totalSec += Math.round(parseFloat(hMatch[1].replace(',', '.')) * 3600); + if (mMatch) totalSec += Math.round(parseFloat(mMatch[1].replace(',', '.')) * 60); + if (!hMatch && !mMatch) { + var parts = raw.split(':'); + if (parts.length === 2) { + totalSec = (parseInt(parts[0], 10) || 0) * 3600 + (parseInt(parts[1], 10) || 0) * 60; + } else if (parts.length === 3) { + totalSec = (parseInt(parts[0], 10) || 0) * 3600 + (parseInt(parts[1], 10) || 0) * 60 + (parseInt(parts[2], 10) || 0); + } + } + return totalSec > 0 ? totalSec : null; + } + + async function loadTasks(projectId) { + if (!taskSelect) return; + taskSelect.innerHTML = ''; + if (!projectId) return; + try { + var url = tasksApiTemplate.replace('/0/', '/' + projectId + '/'); + var res = await fetch(url, { credentials: 'same-origin' }); + if (!res.ok) return; + var data = await res.json(); + (data.tasks || []).forEach(function(task) { + var opt = document.createElement('option'); + opt.value = task.id; + opt.textContent = task.name; + taskSelect.appendChild(opt); + }); + } catch (e) { /* ignore */ } + } + + if (projectSelect) { + projectSelect.addEventListener('change', function() { + loadTasks(this.value); + }); + } + + form.addEventListener('submit', async function(e) { + e.preventDefault(); + if (errorEl) { errorEl.classList.add('hidden'); errorEl.textContent = ''; } + var durationSec = parseDuration(document.getElementById('quickLogDuration').value); + if (!durationSec) { + if (errorEl) { + errorEl.textContent = {{ _('Enter a valid duration (e.g. 2h 30m or 2.5)')|tojson }}; + errorEl.classList.remove('hidden'); + } + return; + } + var dateVal = document.getElementById('quickLogDate').value; + var projectId = projectSelect ? projectSelect.value : ''; + if (!projectId) { + if (errorEl) { + errorEl.textContent = {{ _('Please select a project')|tojson }}; + errorEl.classList.remove('hidden'); + } + return; + } + var endDt = new Date(dateVal + 'T12:00:00'); + var startDt = new Date(endDt.getTime() - durationSec * 1000); + var payload = { + project_id: parseInt(projectId, 10), + task_id: taskSelect && taskSelect.value ? parseInt(taskSelect.value, 10) : null, + start_time: startDt.toISOString(), + end_time: endDt.toISOString(), + notes: (document.getElementById('quickLogNotes').value || '').trim() || null, + billable: true + }; + var submitBtn = document.getElementById('quickLogSubmit'); + if (submitBtn) submitBtn.disabled = true; + try { + var res = await fetch('/api/entries', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(payload) + }); + var body = await res.json().catch(function() { return {}; }); + if (!res.ok) { + throw new Error(body.error || body.message || 'Request failed'); + } + if (window.toastManager && window.toastManager.success) { + window.toastManager.success({{ _('Time logged successfully')|tojson }}, '', 3000); + } + form.reset(); + if (document.getElementById('quickLogDate')) { + document.getElementById('quickLogDate').value = dateVal; + } + setPanelOpen(false); + window.location.reload(); + } catch (err) { + if (errorEl) { + errorEl.textContent = err.message || {{ _('Could not log time')|tojson }}; + errorEl.classList.remove('hidden'); + } + } finally { + if (submitBtn) submitBtn.disabled = false; + } + }); + })(); + + // Tasks due today: inline complete + (function initTasksDueToday() { + document.querySelectorAll('.task-due-complete').forEach(function(checkbox) { + checkbox.addEventListener('change', async function() { + if (!this.checked) return; + var taskId = this.getAttribute('data-task-id'); + var row = this.closest('[data-task-id]'); + this.disabled = true; + try { + var res = await fetch('/api/tasks/' + taskId + '/status', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ status: 'done' }) + }); + if (!res.ok) throw new Error('Failed'); + if (row) { + row.style.opacity = '0.5'; + row.querySelector('a').classList.add('line-through'); + } + if (window.toastManager && window.toastManager.success) { + window.toastManager.success({{ _('Task completed')|tojson }}, '', 2000); + } + } catch (e) { + this.checked = false; + this.disabled = false; + if (window.toastManager && window.toastManager.error) { + window.toastManager.error({{ _('Could not complete task')|tojson }}, '', 3000); + } + } + }); + }); + })(); + + // Soft refresh dashboard timer hero when timer stops externally (idle detection, floating bar) + document.addEventListener('tt:timer-status-changed', async function() { + try { + var res = await fetch('/timer/status', { credentials: 'same-origin' }); + var data = await res.json(); + var hero = document.querySelector('[data-timer-start]'); + if (!data.active && hero) { + window.location.reload(); + } + } catch (e) { /* ignore */ } + }); + // Live timer: update elapsed every second when a timer is running; also live Today/Week/Month hours (function initLiveTimer() { var timerWidget = document.querySelector('[data-timer-start]'); From c7b49f562d503f67dd2f2f9552127622fb0d4a55 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 18:06:15 +0200 Subject: [PATCH 12/18] Enhance recurring tasks list with filters, toggles, and run-now. Add status and project filters, active/inactive switches, last-run column, and a run-now endpoint for immediate task generation. --- app/routes/recurring_tasks.py | 64 ++++++++-- app/templates/recurring_tasks/list.html | 153 ++++++++++++++++++++++-- 2 files changed, 200 insertions(+), 17 deletions(-) diff --git a/app/routes/recurring_tasks.py b/app/routes/recurring_tasks.py index 8c867350..8a468c99 100644 --- a/app/routes/recurring_tasks.py +++ b/app/routes/recurring_tasks.py @@ -71,14 +71,32 @@ def _apply_recurring_task_fields(recurring_task, fields): @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"]) @@ -213,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/templates/recurring_tasks/list.html b/app/templates/recurring_tasks/list.html index 2265f078..a3ca38ad 100644 --- a/app/templates/recurring_tasks/list.html +++ b/app/templates/recurring_tasks/list.html @@ -15,8 +15,30 @@ breadcrumbs=breadcrumbs ) }} -
    - + @@ -27,17 +49,27 @@ + + {% for task in recurring_tasks %} - + + +
    {{ _('Active') }} {{ _('Name') }} {{ _('Project') }} {{ _('Frequency') }} {{ _('Next Run') }}{{ _('Last Run') }} {{ _('Status') }} {{ _('Actions') }}
    + + {{ task.name }} {% if task.description %} @@ -68,8 +100,17 @@ {{ _('Not scheduled') }} {% endif %} + + {% if task.last_created_at %} + {{ task.last_created_at|user_datetime }} + {% else %} + {{ _('Never') }} + {% endif %} + + - @@ -77,13 +118,16 @@ -
    - +
    + +
    -
    @@ -97,10 +141,99 @@
    {% else %} {{ empty_state( - icon='fas fa-redo', - title=_('No recurring tasks'), - message=_('Create recurring task templates to automatically generate tasks on a schedule.') + 'fas fa-redo', + 'No recurring tasks', + 'Create recurring task templates to automatically generate tasks on a schedule.' ) }} {% endif %} {% endblock %} +{% block scripts_extra %} + +{% endblock %} From 1bfc77d7530b387227f7c2d2e3fd67b54fea723e Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 18:06:15 +0200 Subject: [PATCH 13/18] Declutter navigation and improve mobile More drawer. Rename Time Tracking to Projects & Work, dedupe Reports from Finance, and add productivity, calendar, and recurring tasks to mobile navigation. --- app/templates/partials/_bottom_nav.html | 24 ++++++++++- app/templates/partials/_sidebar.html | 56 +++++++++---------------- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/app/templates/partials/_bottom_nav.html b/app/templates/partials/_bottom_nav.html index 01bbd5e1..cf27c259 100644 --- a/app/templates/partials/_bottom_nav.html +++ b/app/templates/partials/_bottom_nav.html @@ -2,7 +2,7 @@ {% set ep = request.endpoint or '' %} {% set timer_tab_active = ep.startswith('timer.') and ep != 'timer.time_entries_overview' %} {% set reports_in_more = ep.startswith('reports.') and not ep.startswith('scheduled_reports.') and not ep.startswith('custom_reports.') %} -{% set more_tab_active = ep.startswith('invoices.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('expenses.') or ep.startswith('deals.') or ep.startswith('leads.') or reports_in_more or ep.startswith('user.settings') or ep.startswith('admin.users') %} +{% set more_tab_active = ep.startswith('invoices.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('expenses.') or ep.startswith('deals.') or ep.startswith('leads.') or reports_in_more or ep.startswith('user.settings') or ep.startswith('admin.users') or ep == 'main.productivity_dashboard' or ep.startswith('calendar.') or ep.startswith('recurring_tasks.') %} {# Backdrop + drawer sit above the tab bar so taps dismiss correctly #}
    + + + {{ _('My productivity') }} + + {% if is_module_enabled('calendar') %} + + + {{ _('Calendar') }} + + {% endif %} + {% if is_module_enabled('recurring_tasks') and current_user.ui_show_recurring_tasks %} + + + {{ _('Recurring Tasks') }} + + {% endif %} {% if is_module_enabled('tasks') %}

    {% endif %}
  • -
      @@ -290,41 +290,23 @@

      - - + + {{ _('Report Builder') }} + + +
    • + + {{ _('Saved Views') }} + +
    • + {% endif %} + {% if is_module_enabled('scheduled_reports') %} +
    • + + {{ _('Scheduled Reports') }} +
    • {% endif %} {% if is_module_enabled('invoices') %} From 0f0dadac520c9135d82b90c955b42daf102c6368 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 18:06:15 +0200 Subject: [PATCH 14/18] Resolve invoice tax rate and currency from project/client settings. Replace hardcoded EUR and zero tax with TaxRule and custom-field fallbacks, and honor include_expenses when creating invoices from time entries. --- app/services/invoice_service.py | 72 +++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/app/services/invoice_service.py b/app/services/invoice_service.py index 3dc3f1b1..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 From 5d3de5f47ff885a730544d4d001ab97e0a03c01d Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 18:06:16 +0200 Subject: [PATCH 15/18] Fix productivity heatmap and empty_state template errors. Split multi-class strings before classList.add in the heatmap renderer and use the correct empty_state macro arguments in list templates. --- app/templates/main/productivity_dashboard.html | 10 ++++++++-- app/templates/workflows/list.html | 11 +++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/templates/main/productivity_dashboard.html b/app/templates/main/productivity_dashboard.html index 1f294875..362b9f3e 100644 --- a/app/templates/main/productivity_dashboard.html +++ b/app/templates/main/productivity_dashboard.html @@ -292,6 +292,12 @@

      {{ _( return h.toFixed(2).replace(/\.?0+$/, '') + 'h'; } + function addClasses(el, classNames) { + (classNames || '').split(/\s+/).filter(Boolean).forEach(function (cls) { + el.classList.add(cls); + }); + } + function fmtMinutes(mins) { mins = Math.max(0, Math.round(Number(mins) || 0)); if (mins < 60) { return mins + 'm'; } @@ -641,10 +647,10 @@

      {{ _( cell.style.height = '12px'; if (!entry) { cell.classList.add('opacity-30'); - cell.classList.add(cellClasses[0]); + addClasses(cell, cellClasses[0]); } else { var lvl = Math.max(0, Math.min(4, entry.level || 0)); - cell.classList.add(cellClasses[lvl]); + addClasses(cell, cellClasses[lvl]); cell.title = entry.date + ': ' + fmtHours(entry.hours); } colWrap.appendChild(cell); diff --git a/app/templates/workflows/list.html b/app/templates/workflows/list.html index 1d606466..6a770eab 100644 --- a/app/templates/workflows/list.html +++ b/app/templates/workflows/list.html @@ -71,11 +71,14 @@

  • {% else %} +{% set workflow_create_action %} +{{ _('Create Workflow') }} +{% endset %} {{ empty_state( - icon_class='fas fa-sitemap', - title=_('No workflows yet'), - message=_('Create automation rules or start from a template.'), - action_html='' + _('Create Workflow') + '' + 'fas fa-sitemap', + 'No workflows yet', + 'Create automation rules or start from a template.', + workflow_create_action ) }} {% endif %} {% endblock %} From 4eb7e11b7681646c81193a31b1a7b3032e3224ad Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 18:54:26 +0200 Subject: [PATCH 16/18] Add backend support for dashboard alerts, timer switch, and productivity breakdown. Surface upcoming task/milestone deadlines and overdue invoice totals on the dashboard, expose notes autocomplete and in-place project switching for active timers, and add daily-by-project data for the productivity chart API. --- app/routes/api.py | 36 +++++++++++ app/routes/main.py | 94 +++++++++++++++++++++++++++- app/routes/timer.py | 79 +++++++++++++++++++++++ app/routes/workflows.py | 30 ++++++++- app/services/productivity_service.py | 88 ++++++++++++++++++++++++++ app/utils/template_filters.py | 32 ++++++++++ 6 files changed, 357 insertions(+), 2 deletions(-) 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/main.py b/app/routes/main.py index dd0db124..7d5d9003 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -20,7 +20,7 @@ from sqlalchemy.orm import joinedload from app import csrf, db, limiter, track_event, track_page_view -from app.models import Activity, Client, Project, Settings, Task, 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 @@ -279,6 +279,94 @@ def dashboard(): ) 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: @@ -410,6 +498,8 @@ def dashboard(): "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 @@ -433,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) @@ -445,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/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/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/utils/template_filters.py b/app/utils/template_filters.py index 10b2575b..2602bc8b 100644 --- a/app/utils/template_filters.py +++ b/app/utils/template_filters.py @@ -427,6 +427,38 @@ def format_currency_filter(value, currency_code=None): symbol = currency_symbols.get((currency_code or "").upper(), currency_code or "EUR") return f"{symbol} {num_str}" + @app.template_filter("days_until") + def days_until_filter(d): + """Return signed day count from today to date d (negative = overdue).""" + if d is None: + return None + from datetime import date + + if hasattr(d, "date"): + d = d.date() + return (d - date.today()).days + + @app.template_filter("relative_due_label") + def relative_due_label_filter(d): + """Human-friendly due date label: Today, Tomorrow, In 3 days, Overdue.""" + if d is None: + return "" + from datetime import date + + if hasattr(d, "date"): + d = d.date() + delta = (d - date.today()).days + if delta < 0: + days = abs(delta) + return f"Overdue ({days} day{'s' if days != 1 else ''})" + if delta == 0: + return "Today" + if delta == 1: + return "Tomorrow" + if delta <= 7: + return f"In {delta} days" + return d.strftime("%Y-%m-%d") + @app.template_filter("timeago") def timeago_filter(dt): """Convert a datetime to a 'time ago' string (e.g., '2 hours ago')""" From e7b3aae85267ed6db65ed20fcceea7a211c451f9 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 23 Aug 2026 18:54:28 +0200 Subject: [PATCH 17/18] Improve UX across dashboard, lists, timer bar, and productivity views. Add deadline and invoice alert widgets, relative due-date badges, workflow execution status, project budget bars, floating timer project switch, stacked daily project chart, notes autocomplete, and a clearer idle prompt with trim-to-idle. --- app/static/floating-timer-bar.js | 125 +++++++++++++++++- app/static/idle.js | 26 +++- app/static/notes-autocomplete.js | 104 +++++++++++++++ app/templates/main/dashboard.html | 47 +++++++ .../main/productivity_dashboard.html | 99 ++++++++++++++ app/templates/partials/_topbar.html | 1 + app/templates/projects/list.html | 25 ++-- app/templates/recurring_tasks/list.html | 11 ++ app/templates/timer/manual_entry.html | 1 + app/templates/timer/timer_page.html | 4 +- app/templates/workflows/list.html | 20 ++- 11 files changed, 450 insertions(+), 13 deletions(-) create mode 100644 app/static/notes-autocomplete.js diff --git a/app/static/floating-timer-bar.js b/app/static/floating-timer-bar.js index 4b389b02..61fd361e 100644 --- a/app/static/floating-timer-bar.js +++ b/app/static/floating-timer-bar.js @@ -26,6 +26,9 @@ this.stopLabel = 'Stop'; this.pauseLabel = 'Pause'; this.resumeLabel = 'Resume'; + this.switchLabel = 'Switch project'; + this.projectsCache = null; + this.switchPopover = null; this.init(); } @@ -36,10 +39,115 @@ 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) + '

    ' + + '' + + '
    ' + + '' + + '' + + '
    '; + 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 = ''; + 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 = ''; + } + } + + 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() { @@ -55,6 +163,7 @@ this.timerData = null; this.startTime = null; this.stopElapsedUpdater(); + this.hideSwitchPopover(); this.render(); } syncFabDesktopHide(this.timerData); @@ -191,6 +300,9 @@
    ${elapsed} + @@ -202,6 +314,13 @@
    `; + 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'; @@ -217,6 +336,10 @@ destroy() { this.stopElapsedUpdater(); if (this.pollTimer) clearInterval(this.pollTimer); + if (this.switchPopover) { + this.switchPopover.remove(); + this.switchPopover = null; + } } } @@ -229,7 +352,7 @@ const style = document.createElement('style'); style.textContent = ` .floating-timer-bar__round { cursor: pointer; } - .floating-timer-bar--active { max-width: 11rem; } + .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 4540458f..63be8169 100644 --- a/app/static/idle.js +++ b/app/static/idle.js @@ -82,6 +82,23 @@ } 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; @@ -116,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.'); @@ -129,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(); @@ -148,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() + '
    ' + - '
    ' + + '
    ' + '' + + '' + '' + '
    '; const container = document.getElementById('toast-notification-container') || document.getElementById('flash-messages-container') || document.body; @@ -165,6 +186,7 @@ '
    ' + buildMessage() + '
    ' + '
    ' + '' + + '' + '' + '
    ' + '
    '; 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/templates/main/dashboard.html b/app/templates/main/dashboard.html index 4c51f2f8..1ad14a7b 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -38,6 +38,18 @@ {% endif %} +{% if overdue_invoices_summary %} + +{% endif %} +
    @@ -744,6 +756,41 @@

    {{ _('Task

    {% endif %} + + {% if is_module_enabled('tasks') and upcoming_deadlines %} +
    +
    +
    +
    + +
    +

    {{ _('Upcoming deadlines') }}

    +
    + {{ _('Next 7 days') }} +
    +
      + {% for item in upcoming_deadlines %} + {% set days_left = item.due_date|days_until %} +
    • + + {{ item.due_date|relative_due_label }} + +
      + {{ item.name }} +
      + {% if item.project_name %}{{ item.project_name }}{% endif %} + {% if item.kind == 'milestone' %}{{ _('Milestone') }}{% else %}{{ _('Task') }}{% endif %} · {{ item.due_date|local_date }} +
      +
      +
    • + {% endfor %} +
    +
    + {% endif %} +
    diff --git a/app/templates/main/productivity_dashboard.html b/app/templates/main/productivity_dashboard.html index 362b9f3e..8c275649 100644 --- a/app/templates/main/productivity_dashboard.html +++ b/app/templates/main/productivity_dashboard.html @@ -132,6 +132,20 @@

    {{ _('Dail

    + {# ---------- Row 2b — Daily breakdown by project (stacked) ---------- #} +
    +
    +
    +

    {{ _('Daily breakdown by project') }}

    +

    {{ _('Last 14 days — where your time went each day') }}

    +
    +
    +
    + +
    + +
    + {# ---------- Row 3 — projects + focus ---------- #}
    {# Project breakdown doughnut (60%) #} @@ -242,6 +256,7 @@

    {{ _( {{ { 'summary': summary, 'daily_breakdown': daily_breakdown, + 'daily_project_breakdown': daily_project_breakdown, 'streak': streak, 'focus': focus, 'projects': projects, @@ -269,6 +284,7 @@

    {{ _( var state = { data: null, dailyChart: null, + dailyProjectChart: null, projectChart: null, sparkChart: null, refreshTimer: null, @@ -421,6 +437,88 @@

    {{ _( state.dailyChart._tooltipMeta = { dates: dates, billable: billable, counts: counts }; } + // --------------- Chart 1b: Daily stacked bars by project --------------- + function renderDailyProjectStackedChart() { + var breakdown = (state.data && state.data.daily_project_breakdown) || {}; + var dates = breakdown.dates || []; + var projects = breakdown.projects || []; + var hoursByDay = breakdown.hours_by_day_project || {}; + var ctxEl = document.getElementById('productivityDailyProjectChart'); + var emptyEl = document.getElementById('productivityDailyProjectEmpty'); + if (!ctxEl) { return; } + + var labels = dates.map(fmtDateLabel); + var datasets = projects.map(function (p) { + var pid = String(p.id == null ? 0 : p.id); + return { + label: p.name, + data: dates.map(function (day) { + var dayMap = hoursByDay[day] || {}; + return Number(dayMap[pid] || 0); + }), + backgroundColor: p.color || '#3b82f6', + borderRadius: 4, + borderSkipped: false, + stack: 'daily', + }; + }); + + var hasData = datasets.some(function (ds) { + return (ds.data || []).some(function (v) { return v > 0; }); + }); + if (emptyEl) { emptyEl.classList.toggle('hidden', hasData); } + + if (state.dailyProjectChart) { + state.dailyProjectChart.data.labels = labels; + state.dailyProjectChart.data.datasets = datasets; + state.dailyProjectChart.update(); + return; + } + + var ctx = ctxEl.getContext('2d'); + state.dailyProjectChart = new Chart(ctx, { + type: 'bar', + data: { labels: labels, datasets: datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: projects.length > 0, + position: 'bottom', + labels: { color: tickColor(), boxWidth: 12 }, + }, + tooltip: { + callbacks: { + title: function (items) { + if (!items.length) { return ''; } + var idx = items[0].dataIndex; + return dates[idx] ? fmtFullDate(dates[idx]) : items[0].label; + }, + label: function (item) { + if (!item.parsed.y) { return null; } + return item.dataset.label + ': ' + fmtHours(item.parsed.y); + }, + }, + }, + }, + scales: { + x: { + stacked: true, + grid: { color: gridColor() }, + ticks: { color: tickColor() }, + }, + y: { + stacked: true, + beginAtZero: true, + grid: { color: gridColor() }, + ticks: { color: tickColor() }, + }, + }, + }, + }); + } + // --------------- Chart 2: Project doughnut --------------- function renderProjectChart() { var projects = (state.data && state.data.projects) || []; @@ -774,6 +872,7 @@

    {{ _( renderSummary(); renderInsights(); renderDailyChart(); + renderDailyProjectStackedChart(); renderProjectChart(); renderSparkline(); renderHeatmap(); diff --git a/app/templates/partials/_topbar.html b/app/templates/partials/_topbar.html index 00535d99..c407a7e5 100644 --- a/app/templates/partials/_topbar.html +++ b/app/templates/partials/_topbar.html @@ -51,6 +51,7 @@ data-daily-target-hours="{{ current_user.standard_hours_per_day or 8 }}" data-dashboard-url="{{ url_for('main.dashboard') }}" data-manual-url="{{ url_for('timer.manual_entry') }}" + data-switch-label="{{ _('Switch project') }}" aria-label="{{ _('Timer') }}">

    {% endif %} diff --git a/app/templates/projects/list.html b/app/templates/projects/list.html index 0703e47b..c52cba09 100644 --- a/app/templates/projects/list.html +++ b/app/templates/projects/list.html @@ -192,14 +192,23 @@

    {% set consumed = (project.budget_consumed_amount or 0.0) %} {% set total = project.budget_amount|float %} {% set pct = (consumed / total * 100) if total > 0 else 0 %} - {% if pct >= 90 %} - {% set badge_classes = 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' %} - {% elif pct >= 70 %} - {% set badge_classes = 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' %} - {% else %} - {% set badge_classes = 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' %} - {% endif %} - {{ pct|round(0) }}% +
    +
    + + {{ pct|round(0) }}% + +
    +
    +
    +
    +
    {% else %} — {% endif %} diff --git a/app/templates/recurring_tasks/list.html b/app/templates/recurring_tasks/list.html index a3ca38ad..e07b1bdc 100644 --- a/app/templates/recurring_tasks/list.html +++ b/app/templates/recurring_tasks/list.html @@ -95,7 +95,18 @@

    {% if task.next_run_date %} + {% set days_left = task.next_run_date|days_until %} {{ task.next_run_date|local_date }} +
    + + {{ task.next_run_date|relative_due_label }} + +
    {% else %} {{ _('Not scheduled') }} {% endif %} diff --git a/app/templates/timer/manual_entry.html b/app/templates/timer/manual_entry.html index 3d429fae..b793f951 100644 --- a/app/templates/timer/manual_entry.html +++ b/app/templates/timer/manual_entry.html @@ -1384,4 +1384,5 @@

    {% endblock %} diff --git a/app/templates/timer/timer_page.html b/app/templates/timer/timer_page.html index 46356b2a..da27c257 100644 --- a/app/templates/timer/timer_page.html +++ b/app/templates/timer/timer_page.html @@ -217,7 +217,8 @@

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

    - + + {% if templates %} @@ -306,6 +307,7 @@

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

    {% endblock %} {% block scripts_extra %} +