diff --git a/app/config.py b/app/config.py index 7e2c227..74bcd4b 100644 --- a/app/config.py +++ b/app/config.py @@ -134,6 +134,9 @@ class Config: # Support & Purchase Key page URL (for links to purchase a key to hide donate UI) SUPPORT_PURCHASE_URL = os.getenv("SUPPORT_PURCHASE_URL", "https://timetracker.drytrix.com/support.html").strip() + SUPPORT_PORTAL_BASE = os.getenv("SUPPORT_PORTAL_BASE", "https://timetracker.drytrix.com").strip() + # Optional one-line social proof for support modal (empty = omit block) + SUPPORT_SOCIAL_PROOF_TEXT = os.getenv("SUPPORT_SOCIAL_PROOF_TEXT", "").strip() # Backup settings BACKUP_RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", 30)) diff --git a/app/config/support_ui.py b/app/config/support_ui.py new file mode 100644 index 0000000..27ea524 --- /dev/null +++ b/app/config/support_ui.py @@ -0,0 +1,59 @@ +"""Non-translated support/checkout configuration (URLs, numeric defaults).""" + +from __future__ import annotations + +import os +from typing import Any, Dict + + +def get_support_portal_base(config: Dict[str, Any] | Any) -> str: + """Marketing site base; defaults to drytrix TimeTracker domain.""" + if hasattr(config, "get"): + raw = (config.get("SUPPORT_PORTAL_BASE") or "").strip() + else: + raw = "" + if not raw: + raw = os.getenv("SUPPORT_PORTAL_BASE", "https://timetracker.drytrix.com").strip() + return raw.rstrip("/") + + +def build_support_checkout_urls(config: Dict[str, Any] | Any) -> Dict[str, str]: + """ + Per-tier outbound URLs. Unset env vars fall back to SUPPORT_PURCHASE_URL so checkout stays one hop. + """ + if hasattr(config, "get"): + purchase = (config.get("SUPPORT_PURCHASE_URL") or "").strip() + else: + purchase = "" + if not purchase: + purchase = os.getenv( + "SUPPORT_PURCHASE_URL", "https://timetracker.drytrix.com/support.html" + ).strip() + + def _tier(env_name: str) -> str: + v = os.getenv(env_name, "").strip() + return v or purchase + + return { + "eur5": _tier("SUPPORT_DONATE_EUR5_URL"), + "eur10": _tier("SUPPORT_DONATE_EUR10_URL"), + "eur25": _tier("SUPPORT_DONATE_EUR25_URL"), + "license": purchase, + } + + +def get_long_session_minutes() -> int: + try: + return max(30, int(os.getenv("SUPPORT_LONG_SESSION_MINUTES", "120"))) + except ValueError: + return 120 + + +def get_social_proof_text(config: Dict[str, Any] | Any) -> str: + if hasattr(config, "get"): + t = (config.get("SUPPORT_SOCIAL_PROOF_TEXT") or "").strip() + else: + t = "" + if not t: + t = os.getenv("SUPPORT_SOCIAL_PROOF_TEXT", "").strip() + return t diff --git a/app/models/donation_interaction.py b/app/models/donation_interaction.py index fa84e09..d1695b9 100644 --- a/app/models/donation_interaction.py +++ b/app/models/donation_interaction.py @@ -5,6 +5,8 @@ Canonical interaction_type values (funnel): - banner_impression: support banner was shown (measured client-side) - banner_dismissed: user dismissed the banner - link_clicked: user clicked a support CTA (donate or key; segment by source) + - support_modal_opened, support_donation_clicked, support_license_clicked: support modal funnel + - support_prompt_shown, support_prompt_dismissed: soft prompt funnel Canonical source values for CTR per placement: - header, banner, banner_bmc, banner_paypal, banner_key @@ -76,17 +78,15 @@ class DonationInteraction(db.Model): @staticmethod def has_recent_donation_click(user_id: int, days: int = 30) -> bool: - """Check if user clicked donation link in last N days""" + """Check if user clicked a donation/support outbound link in last N days.""" cutoff = datetime.utcnow() - timedelta(days=days) + interaction_types = ("link_clicked", "banner_clicked") return ( - DonationInteraction.query.filter_by(user_id=user_id, interaction_type="banner_clicked") - .filter(DonationInteraction.created_at >= cutoff) - .first() - is not None - ) or ( - DonationInteraction.query.filter_by(user_id=user_id, interaction_type="link_clicked") - .filter(DonationInteraction.created_at >= cutoff) - .first() + DonationInteraction.query.filter( + DonationInteraction.user_id == user_id, + DonationInteraction.interaction_type.in_(interaction_types), + DonationInteraction.created_at >= cutoff, + ).first() is not None ) diff --git a/app/models/user.py b/app/models/user.py index 59178b4..69821ee 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -144,6 +144,9 @@ class User(UserMixin, db.Model): ui_show_kiosk = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Kiosk Mode ui_show_donate = db.Column(db.Boolean, default=True, nullable=False) # Show/hide donate/support UI + # Support UX: count of report generations (exports + custom report views) for stats in support modal + support_stats_reports_generated = db.Column(db.Integer, default=0, nullable=False) + # Relationships time_entries = db.relationship("TimeEntry", backref="user", lazy="dynamic", cascade="all, delete-orphan") project_costs = db.relationship("ProjectCost", backref="user", lazy="dynamic", cascade="all, delete-orphan") diff --git a/app/routes/custom_reports.py b/app/routes/custom_reports.py index 826c311..7eccd1a 100644 --- a/app/routes/custom_reports.py +++ b/app/routes/custom_reports.py @@ -14,6 +14,7 @@ from app.models import Client, Expense, Invoice, Project, SavedReportView, Task, from app.services.unpaid_hours_service import UnpaidHoursService from app.utils.db import safe_commit from app.utils.module_helpers import module_enabled +from app.utils.support_report_generation import record_report_generation_for_current_user custom_reports_bp = Blueprint("custom_reports", __name__) @@ -203,11 +204,13 @@ def view_custom_report(view_id): # Check if iterative report generation is enabled if saved_view.iterative_report_generation and saved_view.iterative_custom_field_name: # Generate reports for each custom field value + record_report_generation_for_current_user() return _generate_iterative_reports(saved_view, config, current_user.id) # Generate single report data based on config report_data = generate_report_data(config, current_user.id) + record_report_generation_for_current_user() return render_template("reports/custom_view.html", saved_view=saved_view, config=config, report_data=report_data) diff --git a/app/routes/main.py b/app/routes/main.py index 55f8905..2552be4 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -14,10 +14,11 @@ from flask import ( session, url_for, ) +from flask_babel import gettext as _ from flask_login import current_user, login_required from sqlalchemy import text -from app import db, track_page_view +from app import db, track_event, track_page_view from app.models import Activity, Client, Project, Settings, TimeEntry, TimeEntryTemplate, User, WeeklyTimeGoal from app.models.time_entry import local_now from app.utils.license_utils import is_license_activated @@ -190,10 +191,12 @@ def dashboard(): timer_stopped_toast["time_entries_url"] = url_for("timer.time_entries_overview") # Get user stats for smart banner and donation widget + support_banner_suppressed_dashboard = False try: from app.models import DonationInteraction user_stats = DonationInteraction.get_user_engagement_metrics(current_user.id) + support_banner_suppressed_dashboard = DonationInteraction.has_recent_donation_click(current_user.id, days=30) except Exception: # Fallback if table doesn't exist yet days_since_signup = (datetime.utcnow() - current_user.created_at).days if current_user.created_at else 0 @@ -209,11 +212,40 @@ def dashboard(): time_entries_count = user_stats.get("time_entries_count", 0) total_hours = user_stats.get("total_hours", 0.0) - # Optional support reminder: show at most once per session for unlicensed instances settings_obj = Settings.get_settings() - show_support_reminder = not is_license_activated(settings_obj) and not session.get("support_reminder_shown", False) - if show_support_reminder: - session["support_reminder_shown"] = True + from app.services.support_prompt_service import SupportPromptService + from app.services.usage_stats_service import UsageStatsService + + usage_support_stats = UsageStatsService.get_for_user(current_user.id, month_hours=float(month_hours or 0)) + is_supporter = is_license_activated(settings_obj) + ui_show_donate = getattr(current_user, "ui_show_donate", True) + support_dashboard_prompt = SupportPromptService.pick_dashboard_prompt( + session, + user_stats, + ui_show_donate=ui_show_donate, + is_supporter=is_supporter, + support_banner_suppressed=support_banner_suppressed_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_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." + ), + } + 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." + ), + } # Prepare template data template_data = { @@ -246,7 +278,9 @@ def dashboard(): "time_entries_count": time_entries_count, # For donation widget "total_hours": total_hours, # For donation widget "timer_stopped_toast": timer_stopped_toast, - "show_support_reminder": show_support_reminder, + "usage_support_stats": usage_support_stats, + "support_dashboard_prompt": support_dashboard_prompt, + "is_supporter_instance": is_supporter, } return render_template("main/dashboard.html", **template_data) @@ -398,6 +432,79 @@ def track_support_impression(): return jsonify({"success": True, "note": "Tracking unavailable"}) +@main_bp.route("/donate/request-soft-prompt", methods=["POST"]) +@login_required +def request_soft_support_prompt(): + """Authorize a single long-session soft prompt (session rules enforced server-side).""" + from app.models import DonationInteraction, Settings + + from app.services.support_prompt_service import SupportPromptService + + data = request.get_json() or {} + kind = (data.get("kind") or "long_session").strip() + if kind != "long_session": + return jsonify({"show": False}) + + settings_obj = Settings.get_settings() + is_supporter = is_license_activated(settings_obj) + ui_show = getattr(current_user, "ui_show_donate", True) + try: + suppressed = DonationInteraction.has_recent_donation_click(current_user.id, days=30) + except Exception: + suppressed = False + + if not SupportPromptService.long_session_prompt_allowed( + session, + ui_show_donate=ui_show, + is_supporter=is_supporter, + support_banner_suppressed=suppressed, + ): + return jsonify({"show": False}) + + SupportPromptService.mark_prompt_shown(session, SupportPromptService.VARIANT_LONG_SESSION) + return jsonify({"show": True, "variant": "long_session"}) + + +@main_bp.route("/donate/track-support-event", methods=["POST"]) +@login_required +def track_support_event(): + """Telemetry + DonationInteraction funnel for support UI (best-effort).""" + from app.models import DonationInteraction + + data = request.get_json() or {} + event = (data.get("event") or "").strip() + variant = data.get("variant") + source = (data.get("source") or "support_ui").strip() + + event_map = { + "modal_opened": ("support.modal_opened", "support_modal_opened"), + "donation_clicked": ("support.donation_clicked", "support_donation_clicked"), + "license_clicked": ("support.license_clicked", "support_license_clicked"), + "prompt_shown": ("support.prompt_shown", "support_prompt_shown"), + "prompt_dismissed": ("support.prompt_dismissed", "support_prompt_dismissed"), + } + if event not in event_map: + return jsonify({"success": False, "error": "unknown event"}), 400 + + analytics_name, interaction_type = event_map[event] + props = {"variant": variant, "source": source} + track_event(current_user.id, analytics_name, props) + + try: + metrics = DonationInteraction.get_user_engagement_metrics(current_user.id) + DonationInteraction.record_interaction( + user_id=current_user.id, + interaction_type=interaction_type, + source=source, + user_metrics=metrics, + variant=variant, + ) + except Exception: + pass + + return jsonify({"success": True}) + + @main_bp.route("/debug/i18n") @login_required def debug_i18n(): diff --git a/app/routes/reports.py b/app/routes/reports.py index 0919ff4..fa32b48 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -25,6 +25,7 @@ from app.models import ( ) from app.repositories import TimeEntryRepository from app.services.scheduled_report_service import ScheduledReportService +from app.utils.support_report_generation import record_report_generation_for_current_user from app.utils.excel_export import create_project_report_excel, create_time_entries_excel from app.utils.posthog_monitoring import track_error, track_export_performance, track_validation_error @@ -513,6 +514,7 @@ def export_csv(): # Don't let tracking errors break the export pass + record_report_generation_for_current_user() return send_file(io.BytesIO(csv_content), mimetype="text/csv", as_attachment=True, download_name=filename) except Exception: current_app.logger.exception("CSV export failed (reports.export_csv)") @@ -569,6 +571,7 @@ def export_summary_pdf(): current_app.logger.warning("Summary report PDF export failed: %s", e, exc_info=True) flash(_("PDF export failed: %(error)s", error=str(e)), "error") return redirect(url_for("reports.summary_report")) + record_report_generation_for_current_user() filename = f"summary_report_{datetime.utcnow().strftime('%Y%m%d')}.pdf" return send_file( io.BytesIO(pdf_bytes), @@ -977,6 +980,7 @@ def time_entries_export_excel(): "export.excel", {"export_type": "time_entries_report", "num_rows": len(entries)}, ) + record_report_generation_for_current_user() return send_file( output, mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", @@ -1039,6 +1043,7 @@ def time_entries_export_csv(): writer.writerow(row) output.seek(0) filename = f"time_entries_report_{start_date}_to_{end_date}.csv" + record_report_generation_for_current_user() return send_file( io.BytesIO(output.getvalue().encode("utf-8")), mimetype="text/csv", @@ -1118,6 +1123,7 @@ def export_excel(): {"export_type": "time_entries", "num_rows": len(entries), "date_range_days": (end_dt - start_dt).days}, ) + record_report_generation_for_current_user() return send_file( output, mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", diff --git a/app/routes/user.py b/app/routes/user.py index 10c3424..7398c6a 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -218,7 +218,7 @@ def settings(): @user_bp.route("/settings/license", methods=["GET", "POST"]) @login_required def license(): - """License management page: show status, enter key, validate (sets donate_ui_hidden for instance).""" + """License management: supporter key validation (sets donate_ui_hidden / supporter instance flag).""" settings_obj = Settings.get_settings() if request.method == "POST": if is_license_activated(settings_obj): diff --git a/app/services/support_prompt_service.py b/app/services/support_prompt_service.py new file mode 100644 index 0000000..817f05e --- /dev/null +++ b/app/services/support_prompt_service.py @@ -0,0 +1,127 @@ +"""Rules for soft, non-blocking support prompts (session-scoped).""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + + +class SupportPromptService: + """At most one soft prompt per session; respect supporter and donation-click cooldown.""" + + SESSION_SOFT_PROMPT_CONSUMED = "support_soft_prompt_consumed" + SESSION_PROMPT_TRIGGER = "support_prompt_trigger" + SESSION_SEVEN_DAY_OFFERED = "support_prompt_7d_offered" + SESSION_ACTIVE_DAY_OFFERED = "support_prompt_active_day_offered" + + VARIANT_AFTER_REPORT = "after_report" + VARIANT_SEVEN_DAY = "seven_day" + VARIANT_ACTIVE_TODAY = "active_today" + VARIANT_LONG_SESSION = "long_session" + + @staticmethod + def _base_eligible( + session: Dict[str, Any], + *, + ui_show_donate: bool, + is_supporter: bool, + support_banner_suppressed: bool, + ) -> bool: + if not ui_show_donate: + return False + if is_supporter: + return False + if support_banner_suppressed: + return False + if session.get(SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED): + return False + return True + + @staticmethod + def consume_layout_prompt( + session: Dict[str, Any], + *, + ui_show_donate: bool, + is_supporter: bool, + support_banner_suppressed: bool, + ) -> Optional[Dict[str, str]]: + """ + If the user just finished a report export, show one after-report toast on next full page load. + Marks the session as having shown a soft prompt when returning a payload. + """ + if not SupportPromptService._base_eligible( + session, + ui_show_donate=ui_show_donate, + is_supporter=is_supporter, + support_banner_suppressed=support_banner_suppressed, + ): + return None + trigger = session.get(SupportPromptService.SESSION_PROMPT_TRIGGER) + if trigger != SupportPromptService.VARIANT_AFTER_REPORT: + 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"} + + @staticmethod + def pick_dashboard_prompt( + session: Dict[str, Any], + user_stats: Dict[str, Any], + *, + ui_show_donate: bool, + is_supporter: bool, + support_banner_suppressed: bool, + today_hours: float, + ) -> Optional[Dict[str, str]]: + """ + Eligible only on dashboard: milestone (7+ days since signup) or active tracking day. + Does not consume session slot until caller records prompt shown (caller should set consumed). + """ + if not SupportPromptService._base_eligible( + session, + ui_show_donate=ui_show_donate, + is_supporter=is_supporter, + 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: + return None + + days = int(user_stats.get("days_since_signup") or 0) + if days >= 7 and not session.get(SupportPromptService.SESSION_SEVEN_DAY_OFFERED): + return {"variant": SupportPromptService.VARIANT_SEVEN_DAY, "source": "dashboard"} + + if float(today_hours or 0) >= 4.0 and not session.get(SupportPromptService.SESSION_ACTIVE_DAY_OFFERED): + return {"variant": SupportPromptService.VARIANT_ACTIVE_TODAY, "source": "dashboard"} + + return None + + @staticmethod + 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_ACTIVE_TODAY: + session[SupportPromptService.SESSION_ACTIVE_DAY_OFFERED] = True + elif variant == SupportPromptService.VARIANT_LONG_SESSION: + pass + + @staticmethod + def long_session_prompt_allowed( + session: Dict[str, Any], + *, + ui_show_donate: bool, + is_supporter: bool, + support_banner_suppressed: bool, + ) -> bool: + """JSON endpoint: allow long-session nudge only if no prompt consumed yet this session.""" + if not SupportPromptService._base_eligible( + session, + ui_show_donate=ui_show_donate, + is_supporter=is_supporter, + support_banner_suppressed=support_banner_suppressed, + ): + return False + if session.get(SupportPromptService.SESSION_PROMPT_TRIGGER) == SupportPromptService.VARIANT_AFTER_REPORT: + return False + return True diff --git a/app/services/usage_stats_service.py b/app/services/usage_stats_service.py new file mode 100644 index 0000000..bb9fa7a --- /dev/null +++ b/app/services/usage_stats_service.py @@ -0,0 +1,56 @@ +"""Aggregated usage stats for support modal, dashboard widget, and prompts.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from app import db + + +class UsageStatsService: + """Read/write lightweight counters and engagement metrics for support UI.""" + + @staticmethod + def get_for_user(user_id: int, month_hours: Optional[float] = None) -> Dict[str, Any]: + from app.models import DonationInteraction, User + + base = DonationInteraction.get_user_engagement_metrics(user_id) or {} + reports_count = 0 + try: + u = db.session.get(User, user_id) + if u is not None: + reports_count = int(getattr(u, "support_stats_reports_generated", 0) or 0) + except Exception: + reports_count = 0 + + out = { + "total_hours": float(base.get("total_hours") or 0.0), + "time_entries_count": int(base.get("time_entries_count") or 0), + "days_since_signup": int(base.get("days_since_signup") or 0), + "reports_generated_count": reports_count, + } + if month_hours is not None: + out["month_hours"] = float(month_hours) + return out + + @staticmethod + def increment_reports_generated(user_id: int) -> None: + """Persist +1 report generation (export or custom report view). Never raises.""" + if not user_id: + return + try: + from sqlalchemy import text + + db.session.execute( + text( + "UPDATE users SET support_stats_reports_generated = " + "COALESCE(support_stats_reports_generated, 0) + 1 WHERE id = :uid" + ), + {"uid": user_id}, + ) + db.session.commit() + except Exception: + try: + db.session.rollback() + except Exception: + pass diff --git a/app/static/support-ui.js b/app/static/support-ui.js new file mode 100644 index 0000000..beeda28 --- /dev/null +++ b/app/static/support-ui.js @@ -0,0 +1,275 @@ +/** + * Support modal, header pulse, offline-aware outbound links, soft prompts. + * Copy lives in Jinja / JSON (support_ui_json); this file is behavior only. + */ +(function () { + 'use strict'; + + function getCsrfToken() { + var meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute('content') || '' : ''; + } + + function parseSupportConfig() { + var el = document.getElementById('support-ui-bootstrap'); + if (!el || !el.textContent) return null; + try { + return JSON.parse(el.textContent); + } catch (e) { + return null; + } + } + + function postTrack(cfg, event, extra) { + if (!cfg || !cfg.trackUrl) return; + var body = Object.assign({ event: event }, extra || {}); + fetch(cfg.trackUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify(body), + credentials: 'same-origin' + }).catch(function () {}); + } + + function applyOfflineState(cfg) { + var offlineEl = document.getElementById('supportModalOffline'); + var tierBtns = document.querySelectorAll('a.support-tier-btn'); + var online = typeof navigator !== 'undefined' && navigator.onLine; + if (!offlineEl) return; + if (!online && cfg && cfg.i18n && cfg.i18n.offlineNote) { + offlineEl.textContent = cfg.i18n.offlineNote; + offlineEl.classList.remove('hidden'); + tierBtns.forEach(function (a) { + a.setAttribute('tabindex', '-1'); + a.classList.add('pointer-events-none', 'opacity-50'); + }); + } else { + offlineEl.classList.add('hidden'); + tierBtns.forEach(function (a) { + a.removeAttribute('tabindex'); + a.classList.remove('pointer-events-none', 'opacity-50'); + }); + } + } + + function wireTierLinks(cfg) { + if (!cfg || !cfg.urls) return; + document.querySelectorAll('a.support-tier-btn[data-support-tier]').forEach(function (a) { + var key = a.getAttribute('data-support-tier'); + if (key && cfg.urls[key]) { + a.href = cfg.urls[key]; + } + a.addEventListener('click', function () { + postTrack(cfg, 'donation_clicked', { variant: key, source: 'support_modal' }); + }); + }); + var lic = document.querySelector('a[data-support-tier="license"]'); + if (lic) { + lic.addEventListener('click', function () { + postTrack(cfg, 'license_clicked', { source: 'support_modal' }); + }); + } + } + + function syncStatsFromConfig(cfg) { + if (!cfg || !cfg.stats) return; + var h = document.getElementById('supportStatHours'); + var e = document.getElementById('supportStatEntries'); + var r = document.getElementById('supportStatReports'); + if (h) h.textContent = Number(cfg.stats.total_hours || 0).toFixed(1); + if (e) e.textContent = String(cfg.stats.time_entries_count != null ? cfg.stats.time_entries_count : 0); + if (r) r.textContent = String(cfg.stats.reports_generated_count != null ? cfg.stats.reports_generated_count : 0); + var social = document.getElementById('supportSocialLine'); + if (social && cfg.socialProofLine) { + social.textContent = cfg.socialProofLine; + } + } + + function openSupportModal() { + var modal = document.getElementById('supportModal'); + if (!modal) return; + var cfg = parseSupportConfig(); + syncStatsFromConfig(cfg); + applyOfflineState(cfg); + modal.classList.remove('hidden'); + modal.setAttribute('aria-hidden', 'false'); + if (cfg) postTrack(cfg, 'modal_opened', { source: 'support_modal' }); + } + + function closeSupportModal() { + var modal = document.getElementById('supportModal'); + if (!modal) return; + modal.classList.add('hidden'); + modal.setAttribute('aria-hidden', 'true'); + } + + window.openSupportModal = openSupportModal; + window.closeSupportModal = closeSupportModal; + + function showSoftToast(cfg, message, variant, source) { + if (!window.toastManager || typeof window.toastManager.show !== 'function') return; + window.toastManager.show({ + message: message, + type: 'info', + duration: 8000, + dismissible: true, + actionLink: '__support_modal__', + actionLabel: (cfg && cfg.i18n && cfg.i18n.supportAction) || 'Support' + }); + postTrack(cfg, 'prompt_shown', { variant: variant, source: source || 'toast' }); + } + + function maybeLongSessionPrompt(cfg) { + if (!cfg || !cfg.sessionStartedAt || !cfg.softPromptUrl) return; + var mins = Number(cfg.longSessionMinutes) || 120; + var started = Date.parse(cfg.sessionStartedAt); + if (!started) return; + + function check() { + var elapsedMin = (Date.now() - started) / 60000; + if (elapsedMin < mins) return; + clearInterval(timer); + fetch(cfg.softPromptUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify({ kind: 'long_session' }), + credentials: 'same-origin' + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data || !data.show) return; + var msg = + (cfg.i18n && cfg.i18n.longSessionToast) || + 'If TimeTracker helps your day, consider supporting its development.'; + var act = (cfg.i18n && cfg.i18n.supportAction) || 'Support'; + if (window.toastManager && typeof window.toastManager.show === 'function') { + window.toastManager.show({ + message: msg, + type: 'info', + duration: 9000, + dismissible: true, + actionLink: '__support_modal__', + actionLabel: act + }); + } + postTrack(cfg, 'prompt_shown', { variant: 'long_session', source: 'long_session_timer' }); + }) + .catch(function () {}); + } + + var timer = setInterval(check, 60000); + setTimeout(check, 5000); + } + + function layoutPromptFromConfig(cfg) { + if (!cfg || !cfg.layoutPrompt || !cfg.layoutPrompt.message) return; + showSoftToast(cfg, cfg.layoutPrompt.message, cfg.layoutPrompt.variant || 'after_report', 'layout'); + } + + function dashboardPrompt() { + var cfg = parseSupportConfig(); + var raw = window.__TT_DASHBOARD_SUPPORT_PROMPT; + if (!cfg || !raw || !raw.message) return; + showSoftToast(cfg, raw.message, raw.variant || 'dashboard', raw.source || 'dashboard'); + } + + function headerPulse(btn) { + if (!btn) return; + try { + if (sessionStorage.getItem('tt_support_header_pulse_done')) return; + btn.classList.add('animate-pulse', 'ring-2', 'ring-amber-400/60'); + setTimeout(function () { + btn.classList.remove('animate-pulse', 'ring-2', 'ring-amber-400/60'); + }, 2400); + sessionStorage.setItem('tt_support_header_pulse_done', '1'); + } catch (e) {} + } + + function wireModalDom(cfg) { + var modal = document.getElementById('supportModal'); + if (!modal) return; + modal.querySelectorAll('[data-support-modal-close], [data-support-modal-overlay]').forEach(function (el) { + el.addEventListener('click', function () { + closeSupportModal(); + }); + }); + document.addEventListener('keydown', function (ev) { + if (ev.key === 'Escape' && !modal.classList.contains('hidden')) { + closeSupportModal(); + } + }); + var shareBtn = document.getElementById('supportShareBtn'); + if (shareBtn && cfg && cfg.shareUrl) { + shareBtn.addEventListener('click', function () { + var url = cfg.shareUrl; + if (navigator.share) { + navigator + .share({ + title: document.title, + url: url + }) + .catch(function () {}); + } else if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url).then( + function () { + if (window.toastManager) { + window.toastManager.show( + (cfg.i18n && cfg.i18n.shareSuccess) || 'Copied', + 'success' + ); + } + }, + function () { + if (window.toastManager) { + window.toastManager.show( + (cfg.i18n && cfg.i18n.shareFail) || 'Copy failed', + 'error' + ); + } + } + ); + } + }); + } + var hdr = document.getElementById('headerSupportBtn'); + if (hdr) { + hdr.addEventListener('click', function (e) { + e.preventDefault(); + openSupportModal(); + }); + headerPulse(hdr); + } + document.querySelectorAll('.js-open-support-modal').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.preventDefault(); + openSupportModal(); + }); + }); + window.addEventListener('online', function () { + applyOfflineState(cfg); + }); + window.addEventListener('offline', function () { + applyOfflineState(cfg); + }); + } + + document.addEventListener('DOMContentLoaded', function () { + var cfg = parseSupportConfig(); + if (!cfg) return; + cfg.i18n = cfg.i18n || {}; + cfg.i18n.supportAction = cfg.i18n.supportAction || 'Support'; + wireTierLinks(cfg); + wireModalDom(cfg); + layoutPromptFromConfig(cfg); + dashboardPrompt(); + maybeLongSessionPrompt(cfg); + }); +})(); diff --git a/app/static/toast-notifications.js b/app/static/toast-notifications.js index 1325cd6..7bd9a27 100644 --- a/app/static/toast-notifications.js +++ b/app/static/toast-notifications.js @@ -250,6 +250,15 @@ class ToastNotificationManager { opacity: '0.95', textDecoration: 'underline' }); + if (config.actionLink === '__support_modal__') { + actionLink.href = '#'; + actionLink.addEventListener('click', function (e) { + e.preventDefault(); + if (typeof window.openSupportModal === 'function') { + window.openSupportModal(); + } + }); + } content.appendChild(actionLink); } diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index abd6a5c..8ba878f 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -184,7 +184,7 @@ {% if settings.donate_ui_hidden %}

- {{ _('Donate and support UI are hidden for all users.') }} + {{ _('Supporter instance: prompts are minimized; support entry points remain available.') }}

{% else %} diff --git a/app/templates/base.html b/app/templates/base.html index c2b6ce7..8162eca 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1054,13 +1054,12 @@ {{ _('Help') }} - {% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %} + {% if current_user.is_authenticated and current_user.ui_show_donate %}
  • - - - {{ _('Support Development') }} - - +
  • {% endif %} @@ -1126,6 +1125,12 @@ + {% if current_user.is_authenticated %} + + {% endif %} @@ -1164,6 +1169,9 @@ {% endif %} + {% if is_license_activated %} + + {% endif %} {% else %}
    @@ -1182,8 +1190,8 @@ {% if current_user.is_authenticated %}
  • {{ _('License') }}
  • {% endif %} - {% if not is_license_activated and current_user.is_authenticated %} -
  • {{ _('Support TimeTracker') }}{{ _('Buy a license key (€25)') }}
  • + {% if current_user.is_authenticated %} +
  • {% endif %}
  • {{ _('Logout') }}
  • @@ -1211,7 +1219,7 @@ {% endwith %}
    - {% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %} + {% if current_user.is_authenticated and current_user.ui_show_donate %} @@ -1245,9 +1253,9 @@ class="px-3 py-1.5 text-white text-sm font-medium rounded-lg transition-colors border border-blue-600 hover:opacity-90" style="background: linear-gradient(to right, #0070ba, #003087);"> {{ _('PayPal') }} - - {{ _('Remove prompts with key') }} - + + +
    +
    +
    +
    {{ _('Hours tracked') }}
    +
    {{ '%.1f'|format(s.get('total_hours', 0)|float) }}
    +
    +
    +
    {{ _('Entries') }}
    +
    {{ s.get('time_entries_count', 0)|int }}
    +
    +
    +
    {{ _('Reports') }}
    +
    {{ s.get('reports_generated_count', 0)|int }}
    +
    +
    +

    {{ _('Trusted by teams and freelancers who want simple, reliable time tracking.') }}

    + +
    + {{ _('Donate') }} (€5) + {{ _('Donate') }} (€10) + {{ _('Donate') }} (€25) +
    +
    + + {{ _('Buy license (€25)') }} + + +
    +

    + {{ _('A license is a supporter badge — it does not lock features. You keep full access either way.') }} +

    +
    + + + diff --git a/app/templates/main/about.html b/app/templates/main/about.html index f7eb03c..3aed10c 100644 --- a/app/templates/main/about.html +++ b/app/templates/main/about.html @@ -40,13 +40,10 @@ -{% if not is_license_activated and current_user.is_authenticated %} - +{% if current_user.is_authenticated and current_user.ui_show_donate %}
    -

    {{ _('TimeTracker is free and open-source. If you enjoy using it, you can support development by purchasing a license key.') }}

    - - {{ _('Buy license (€25)') }} - +

    {{ _('TimeTracker is free and open source. You can donate or buy a supporter license — features are never locked.') }}

    +
    {% endif %} diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index be9c232..ff1cf34 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -534,23 +534,39 @@ - {% if show_support_reminder %} - -
    + {% if current_user.ui_show_donate %} +
    - +

    {{ _('Enjoying TimeTracker?') }}

    -

    - {{ _('You can support development by purchasing a license key.') }} +

    + {{ _('You have tracked %(hours)s hours', hours=('%.1f'|format((usage_support_stats.total_hours or 0)|float))) }} + · {{ _('You have created %(count)s entries', count=usage_support_stats.time_entries_count or 0) }} + {% if (usage_support_stats.reports_generated_count or 0) > 0 %} + · {{ _('Reports generated: %(n)s', n=usage_support_stats.reports_generated_count) }} + {% endif %}

    - - {{ _('Support the project') }} - + {% if is_supporter_instance %} +

    {{ _('Thank you for supporting development. Sharing TimeTracker still helps a lot.') }}

    +
    + + {{ _('License') }} +
    + {% else %} +

    {{ _('If this saves you time, consider supporting development — everything stays free and open.') }}

    +
    + + {{ _('Buy License (€25)') }} +
    + {% endif %}
    {% endif %} + {% if support_dashboard_prompt %} + + {% endif %}
    {% for entry in recent_entries %} diff --git a/app/templates/main/help.html b/app/templates/main/help.html index 78e1519..f2162ee 100644 --- a/app/templates/main/help.html +++ b/app/templates/main/help.html @@ -831,17 +831,17 @@ {{ _('Report Issue') }} - {% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %} - - {{ _('Support updates') }} - + {% if current_user.is_authenticated and current_user.ui_show_donate %} + {% endif %} - {% if not is_license_activated and current_user.is_authenticated %} + {% if current_user.is_authenticated and current_user.ui_show_donate %}

    - {{ _('Enjoying TimeTracker?') }} {{ _('You can support development by purchasing a license key.') }} - {{ _('Support the project') }} + {{ _('Enjoying TimeTracker?') }} {{ _('Donations and licenses fund development; nothing is paywalled.') }} +

    {% endif %} diff --git a/app/templates/reports/index.html b/app/templates/reports/index.html index 28dc1a1..c1a7a8a 100644 --- a/app/templates/reports/index.html +++ b/app/templates/reports/index.html @@ -15,10 +15,10 @@ actions_html=None ) }} -{% if not is_license_activated and current_user.is_authenticated %} +{% if current_user.is_authenticated and current_user.ui_show_donate %}

    - {{ _('Enjoying TimeTracker?') }} {{ _('You can support development by purchasing a license key.') }} - {{ _('Support the project') }} + {{ _('Enjoying TimeTracker?') }} {{ _('Support development or get a supporter license — the app stays free for everyone.') }} +

    {% endif %} diff --git a/app/templates/user/license.html b/app/templates/user/license.html index adadb75..70c86ef 100644 --- a/app/templates/user/license.html +++ b/app/templates/user/license.html @@ -5,7 +5,7 @@

    {{ _('License') }}

    -

    {{ _('View activation status and enter a license key') }}

    +

    {{ _('Supporter badge: confirm your key and thank you for funding development.') }}

    @@ -13,16 +13,16 @@ {% if is_license_activated %}

    - {{ _('Active license') }} + {{ _('Supporter license active') }}

    -

    {{ _('Thank you for supporting TimeTracker.') }}

    +

    {{ _('Thank you for supporting TimeTracker. Your badge confirms this instance as a supporter — no features are locked.') }}

    {% else %}

    {{ _('Not activated') }}

    - {{ _('Want to support development? You can purchase a license key for €25.') }} + {{ _('Purchase a supporter license (€25) to unlock the supporter badge for this instance. The app stays fully free either way.') }}

    {{ _('Buy license (€25)') }} diff --git a/app/templates/user/settings.html b/app/templates/user/settings.html index c86c5e4..2158bcd 100644 --- a/app/templates/user/settings.html +++ b/app/templates/user/settings.html @@ -8,20 +8,27 @@

    {{ _('Manage your account settings and preferences') }}

    - {% if not is_license_activated %} -
    -

    {{ _('TimeTracker is free and open-source. If you enjoy using it, you can support development by purchasing a license key.') }}

    -
    - {{ _('Buy license (€25)') }} - -
    - {% endif %} - -
    - - {{ _('License') }} - -
    +
    + +

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

    + +
    +
    +

    {{ _('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.') }}

    + {% 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.') }}

    + {% endif %} + +
    +
    diff --git a/app/utils/context_processors.py b/app/utils/context_processors.py index fdf2f10..b0e260c 100644 --- a/app/utils/context_processors.py +++ b/app/utils/context_processors.py @@ -1,5 +1,8 @@ -from flask import current_app, g, request -from flask_babel import get_locale +import json +from datetime import datetime + +from flask import current_app, g, request, session, url_for +from flask_babel import get_locale, gettext as _ from flask_login import current_user from app.models import Settings @@ -178,6 +181,93 @@ def register_context_processors(app): getattr(current_user, "is_authenticated", False) and getattr(current_user, "is_admin", False) ) + support_ui_json = None + layout_support_prompt = None + support_usage_stats_modal = None + if getattr(current_user, "is_authenticated", False): + try: + from app.config.support_ui import ( + build_support_checkout_urls, + get_long_session_minutes, + get_social_proof_text, + ) + from app.models import Settings + from app.services.support_prompt_service import SupportPromptService + from app.services.usage_stats_service import UsageStatsService + from app.utils.license_utils import is_license_activated + + settings_obj = Settings.get_settings() + is_supporter_instance = bool(settings_obj and is_license_activated(settings_obj)) + ui_show_donate = bool(getattr(current_user, "ui_show_donate", True)) + + layout_support_prompt = SupportPromptService.consume_layout_prompt( + session, + ui_show_donate=ui_show_donate, + is_supporter=is_supporter_instance, + support_banner_suppressed=support_banner_suppressed, + ) + + usage_stats = UsageStatsService.get_for_user(current_user.id) + support_usage_stats_modal = usage_stats + checkout_urls = build_support_checkout_urls(current_app.config) + social_line = get_social_proof_text(current_app.config) + long_session_minutes = get_long_session_minutes() + + if not session.get("support_session_started_at"): + session["support_session_started_at"] = ( + datetime.utcnow().replace(microsecond=0).isoformat() + "Z" + ) + + lp_message = "" + lp_action = _("Support") + if layout_support_prompt: + 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." + ) + + support_ui_json = json.dumps( + { + "urls": checkout_urls, + "stats": usage_stats, + "socialProofLine": social_line, + "longSessionMinutes": long_session_minutes, + "isSupporter": is_supporter_instance, + "sessionStartedAt": session.get("support_session_started_at"), + "shareUrl": url_for("main.about", _external=True), + "trackUrl": url_for("main.track_support_event"), + "softPromptUrl": url_for("main.request_soft_support_prompt"), + "layoutPrompt": ( + { + "variant": layout_support_prompt.get("variant"), + "message": lp_message, + "actionLabel": lp_action, + } + if layout_support_prompt + else None + ), + "i18n": { + "offlineNote": _( + "You appear to be offline. Reconnect to open donation or checkout links." + ), + "shareSuccess": _("Link copied to clipboard"), + "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." + ), + }, + }, + ensure_ascii=False, + ) + except Exception: + support_ui_json = None + layout_support_prompt = None + support_usage_stats_modal = None + return { "app_name": "Time Tracker", "app_version": version_value, @@ -195,6 +285,9 @@ def register_context_processors(app): "user_stats": user_stats, "support_banner_suppressed": support_banner_suppressed, "support_ab_variant": support_ab_variant, + "support_ui_json": support_ui_json, + "layout_support_prompt": layout_support_prompt, + "support_usage_stats_modal": support_usage_stats_modal, } @app.context_processor diff --git a/app/utils/license_utils.py b/app/utils/license_utils.py index b35d2ad..c1349bb 100644 --- a/app/utils/license_utils.py +++ b/app/utils/license_utils.py @@ -1,12 +1,12 @@ """ Optional support/license visibility helpers. -Instance-level "license activated" state is represented by Settings.donate_ui_hidden -(set when a user verifies the donate-hide / license key). This is non-blocking -monetization awareness only—no paywall or feature gating. +Instance-level supporter state is represented by Settings.donate_ui_hidden +(set when a user verifies a license / supporter key). Non-blocking: no paywall +or feature gating; UI treats this as a supporter badge and softer prompts. """ def is_license_activated(settings) -> bool: - """Return True if this instance has an active license (donate/support UI hidden).""" + """Return True if this instance has an activated supporter / license key.""" return bool(getattr(settings, "donate_ui_hidden", False)) diff --git a/app/utils/support_report_generation.py b/app/utils/support_report_generation.py new file mode 100644 index 0000000..96edaca --- /dev/null +++ b/app/utils/support_report_generation.py @@ -0,0 +1,16 @@ +"""Hook successful report exports/views for support stats and soft prompts.""" + +from __future__ import annotations + + +def record_report_generation_for_current_user() -> None: + """Increment per-user report counter and queue a one-shot support prompt trigger.""" + from flask import session + from flask_login import current_user + + from app.services.usage_stats_service import UsageStatsService + + if not getattr(current_user, "is_authenticated", False): + return + UsageStatsService.increment_reports_generated(current_user.id) + session["support_prompt_trigger"] = "after_report" diff --git a/docs/all_tracked_events.md b/docs/all_tracked_events.md index 2dacba0..528ae31 100644 --- a/docs/all_tracked_events.md +++ b/docs/all_tracked_events.md @@ -73,6 +73,16 @@ This document lists events tracked via PostHog and JSON logging. | `export.csv` | User exports data to CSV | `user_id`, `export_type`, `row_count` | | `export.pdf` | User exports data to PDF | `user_id`, `export_type` | +## Support & donation funnel (opt-in layer) + +| Event Name | Description | Properties | +|-----------|-------------|-------------| +| `support.modal_opened` | User opened the support modal | `variant`, `source` | +| `support.donation_clicked` | User chose a donation tier from the modal | `variant` (tier key), `source` | +| `support.license_clicked` | User opened supporter checkout / license from the modal | `source` | +| `support.prompt_shown` | Soft support toast or prompt was shown | `variant`, `source` | +| `support.prompt_dismissed` | User dismissed a soft support prompt | `variant`, `source` | + ## Comment Events | Event Name | Description | Properties | diff --git a/docs/analytics.md b/docs/analytics.md index f834925..a66b913 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -20,6 +20,7 @@ TimeTracker provides privacy-aware analytics and monitoring with Grafana Cloud O - Product events such as `timer.started`, `project.created`, `auth.login` - Sent only when admins enable detailed analytics in the app - PII-filtered before export +- Support UI funnel events (`support.modal_opened`, `support.donation_clicked`, etc.) are emitted the same way when opt-in is enabled; see [all_tracked_events.md](all_tracked_events.md). ## Configuration @@ -34,8 +35,22 @@ ENABLE_TELEMETRY=true # Optional error monitoring SENTRY_DSN= SENTRY_TRACES_RATE=0.1 + +# Support / checkout links (optional; defaults in app/config.py) +SUPPORT_PURCHASE_URL=https://timetracker.drytrix.com/support.html +SUPPORT_PORTAL_BASE=https://timetracker.drytrix.com +# Optional one line shown in the support modal when set +SUPPORT_SOCIAL_PROOF_TEXT= +# Optional per-tier donate URLs (default to SUPPORT_PURCHASE_URL when unset) +SUPPORT_DONATE_EUR5_URL= +SUPPORT_DONATE_EUR10_URL= +SUPPORT_DONATE_EUR25_URL= +# Long-session soft prompt threshold in minutes (default 120) +SUPPORT_LONG_SESSION_MINUTES=120 ``` +Per-user **report generation counts** for the support modal are stored in `users.support_stats_reports_generated` (see migration `149_add_user_support_stats_reports_generated`). + ## Troubleshooting - If no telemetry arrives, verify `GRAFANA_OTLP_ENDPOINT` and `GRAFANA_OTLP_TOKEN` diff --git a/docs/telemetry-architecture.md b/docs/telemetry-architecture.md index f2f307f..da766ee 100644 --- a/docs/telemetry-architecture.md +++ b/docs/telemetry-architecture.md @@ -23,7 +23,7 @@ This document describes the privacy-aware, two-layer telemetry system: **base te ## Detailed Analytics (Opt-In Only) - **Gated by:** `is_telemetry_enabled()` / `allow_analytics`. No product events sent without opt-in. -- **Events:** Existing names (e.g. `auth.login`, `timer.started`, `project.created`). Optional prefix `analytics.*` in future. +- **Events:** Existing names (e.g. `auth.login`, `timer.started`, `project.created`). Support funnel events use the `support.*` prefix (e.g. `support.modal_opened`); see [all_tracked_events.md](all_tracked_events.md). Optional prefix `analytics.*` in future. - **Properties:** Include `install_id`, app_version, deployment, request context (path, browser, device) only when opted in. - **Sink:** Grafana Cloud OTLP (`identity = user_id` for events). - **Retention:** Per Grafana retention policy. Document in privacy policy. diff --git a/migrations/versions/149_add_user_support_stats_reports_generated.py b/migrations/versions/149_add_user_support_stats_reports_generated.py new file mode 100644 index 0000000..f93b718 --- /dev/null +++ b/migrations/versions/149_add_user_support_stats_reports_generated.py @@ -0,0 +1,43 @@ +"""Add users.support_stats_reports_generated for support modal stats. + +Revision ID: 149_add_user_support_stats_reports_generated +Revises: 148_add_user_dismissed_release_version +Create Date: 2026-04-15 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "149_add_user_support_stats_reports_generated" +down_revision = "148_add_user_dismissed_release_version" +branch_labels = None +depends_on = None + + +def _has_column(inspector, table_name: str, column_name: str) -> bool: + try: + return column_name in {c["name"] for c in inspector.get_columns(table_name)} + except Exception: + return False + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "users" not in inspector.get_table_names(): + return + if not _has_column(inspector, "users", "support_stats_reports_generated"): + op.add_column( + "users", + sa.Column("support_stats_reports_generated", sa.Integer(), nullable=False, server_default="0"), + ) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "users" not in inspector.get_table_names(): + return + if _has_column(inspector, "users", "support_stats_reports_generated"): + op.drop_column("users", "support_stats_reports_generated") diff --git a/tests/test_support_services.py b/tests/test_support_services.py new file mode 100644 index 0000000..67dcfbf --- /dev/null +++ b/tests/test_support_services.py @@ -0,0 +1,63 @@ +"""Tests for support prompt and usage stats services.""" + +from app.services.support_prompt_service import SupportPromptService +from app.services.usage_stats_service import UsageStatsService + + +def test_usage_stats_service_shape(app, test_user): + with app.app_context(): + s = UsageStatsService.get_for_user(test_user.id) + assert "total_hours" in s + assert "time_entries_count" in s + assert "days_since_signup" in s + assert "reports_generated_count" in s + + +def test_consume_layout_prompt_sets_consumed(): + session = {"support_prompt_trigger": SupportPromptService.VARIANT_AFTER_REPORT} + 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_AFTER_REPORT + assert session.get(SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED) is True + assert "support_prompt_trigger" not in session + + +def test_support_prompt_suppressed_for_supporter(): + session = {"support_prompt_trigger": SupportPromptService.VARIANT_AFTER_REPORT} + payload = SupportPromptService.consume_layout_prompt( + session, + ui_show_donate=True, + is_supporter=True, + support_banner_suppressed=False, + ) + assert payload is None + + +def test_support_prompt_respects_ui_show_donate(): + session = {"support_prompt_trigger": SupportPromptService.VARIANT_AFTER_REPORT} + payload = SupportPromptService.consume_layout_prompt( + session, + ui_show_donate=False, + is_supporter=False, + support_banner_suppressed=False, + ) + assert payload is None + + +def test_pick_dashboard_skips_when_after_report_pending(): + session = {"support_prompt_trigger": SupportPromptService.VARIANT_AFTER_REPORT} + user_stats = {"days_since_signup": 100, "time_entries_count": 1, "total_hours": 1.0} + picked = SupportPromptService.pick_dashboard_prompt( + session, + user_stats, + ui_show_donate=True, + is_supporter=False, + support_banner_suppressed=False, + today_hours=8.0, + ) + assert picked is None