From 9507a9492c5a48143670f6eb50471fbb6bbde537 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Tue, 30 Dec 2025 09:52:12 +0100 Subject: [PATCH 1/2] feat: Add comprehensive donation system with smart prompts and improved accessibility - Add dedicated donation page (/donate) explaining why donations matter - Implement DonationInteraction model to track user engagement and interactions - Add smart banner logic with contextual messaging based on user milestones - Improve donation accessibility with links in sidebar, footer, dashboard, and all major pages - Add UTM tracking to all Buy Me a Coffee links for analytics - Fix CSRF token issues in donation tracking JavaScript - Enhance dashboard widget with user stats and dual action buttons - Add donation information section to About page - Update support banner with 'Learn More' and 'Donate' options - Create database migration for donation_interactions table The donation system now provides: - Smart prompts that show after user milestones (7+ days, 50+ entries, 100+ hours) - Banner dismissal tracking with 30-day cooldown - Multiple access points throughout the application - Better visibility of donation impact and importance - Comprehensive tracking for analytics and optimization --- app/models/__init__.py | 2 + app/models/donation_interaction.py | 98 +++++++ app/routes/main.py | 109 ++++++++ app/templates/base.html | 194 ++++++++++++-- app/templates/main/about.html | 29 ++- app/templates/main/dashboard.html | 38 ++- app/templates/main/donate.html | 239 ++++++++++++++++++ app/templates/main/help.html | 4 +- .../versions/094_add_donation_interactions.py | 42 +++ 9 files changed, 726 insertions(+), 29 deletions(-) create mode 100644 app/models/donation_interaction.py create mode 100644 app/templates/main/donate.html create mode 100644 migrations/versions/094_add_donation_interactions.py diff --git a/app/models/__init__.py b/app/models/__init__.py index 52c12ad..d7ccab5 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -80,6 +80,7 @@ from .link_template import LinkTemplate from .custom_field_definition import CustomFieldDefinition from .salesman_email_mapping import SalesmanEmailMapping from .issue import Issue +from .donation_interaction import DonationInteraction __all__ = [ "User", @@ -185,4 +186,5 @@ __all__ = [ "CustomFieldDefinition", "SalesmanEmailMapping", "Issue", + "DonationInteraction", ] diff --git a/app/models/donation_interaction.py b/app/models/donation_interaction.py new file mode 100644 index 0000000..dcde8e0 --- /dev/null +++ b/app/models/donation_interaction.py @@ -0,0 +1,98 @@ +"""Model to track donation banner interactions and user engagement metrics""" + +from datetime import datetime, timedelta +from app import db + + +class DonationInteraction(db.Model): + """Track user interactions with donation prompts""" + + __tablename__ = "donation_interactions" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + + # Interaction type + interaction_type = db.Column( + db.String(50), nullable=False + ) # 'banner_dismissed', 'banner_clicked', 'link_clicked', 'page_viewed' + + # Context + source = db.Column(db.String(100), nullable=True) # 'dashboard', 'banner', 'menu', 'footer', etc. + + # User metrics at time of interaction (for smart prompts) + time_entries_count = db.Column(db.Integer, nullable=True) # Total time entries + days_since_signup = db.Column(db.Integer, nullable=True) # Days since user created account + total_hours = db.Column(db.Float, nullable=True) # Total hours tracked + + # Timestamps + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + user = db.relationship("User", backref="donation_interactions") + + def __repr__(self): + return f"" + + @staticmethod + def record_interaction(user_id: int, interaction_type: str, source: str = None, user_metrics: dict = None): + """Record a donation interaction""" + interaction = DonationInteraction( + user_id=user_id, + interaction_type=interaction_type, + source=source, + ) + + if user_metrics: + interaction.time_entries_count = user_metrics.get("time_entries_count") + interaction.days_since_signup = user_metrics.get("days_since_signup") + interaction.total_hours = user_metrics.get("total_hours") + + db.session.add(interaction) + db.session.commit() + return interaction + + @staticmethod + def has_recent_donation_click(user_id: int, days: int = 30) -> bool: + """Check if user clicked donation link in last N days""" + cutoff = datetime.utcnow() - timedelta(days=days) + 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() + is not None + ) + + @staticmethod + def get_user_engagement_metrics(user_id: int) -> dict: + """Get user engagement metrics for smart prompts""" + from app.models import TimeEntry, User + + user = User.query.get(user_id) + if not user: + return {} + + # Days since signup + days_since_signup = (datetime.utcnow() - user.created_at).days if user.created_at else 0 + + # Time entries count + time_entries_count = TimeEntry.query.filter_by(user_id=user_id).count() + + # Total hours + total_hours = user.total_hours if hasattr(user, "total_hours") else 0.0 + + return { + "days_since_signup": days_since_signup, + "time_entries_count": time_entries_count, + "total_hours": total_hours, + } + diff --git a/app/routes/main.py b/app/routes/main.py index 09cd1bd..28175f2 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -105,6 +105,25 @@ def dashboard(): # Get recent activities for activity feed widget recent_activities = Activity.get_recent(user_id=None if current_user.is_admin else current_user.id, limit=10) + # Get user stats for smart banner and donation widget + try: + from app.models import DonationInteraction + user_stats = DonationInteraction.get_user_engagement_metrics(current_user.id) + 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 + time_entries_count = TimeEntry.query.filter_by(user_id=current_user.id).count() + total_hours = current_user.total_hours if hasattr(current_user, "total_hours") else 0.0 + user_stats = { + "days_since_signup": days_since_signup, + "time_entries_count": time_entries_count, + "total_hours": total_hours, + } + + # Get donation widget stats (separate from user_stats for clarity) + time_entries_count = user_stats.get("time_entries_count", 0) + total_hours = user_stats.get("total_hours", 0.0) + # Prepare template data template_data = { "active_timer": active_timer, @@ -118,6 +137,9 @@ def dashboard(): "current_week_goal": current_week_goal, "templates": templates, "recent_activities": recent_activities, + "user_stats": user_stats, # For smart banner + "time_entries_count": time_entries_count, # For donation widget + "total_hours": total_hours, # For donation widget } # Cache for 5 minutes @@ -154,6 +176,93 @@ def help(): return render_template("main/help.html") +@main_bp.route("/donate") +@login_required +def donate(): + """Donation page explaining why donations are important""" + from app.models import TimeEntry + + # Get user engagement metrics + days_since_signup = (datetime.utcnow() - current_user.created_at).days if current_user.created_at else 0 + time_entries_count = TimeEntry.query.filter_by(user_id=current_user.id).count() + total_hours = current_user.total_hours if hasattr(current_user, "total_hours") else 0.0 + + # Record page view (only if table exists) + try: + from app.models import DonationInteraction + DonationInteraction.record_interaction( + user_id=current_user.id, + interaction_type="page_viewed", + source="donate_page", + user_metrics={ + "days_since_signup": days_since_signup, + "time_entries_count": time_entries_count, + "total_hours": total_hours, + } + ) + except Exception: + # Don't fail if tracking fails (e.g., table doesn't exist yet) + pass + + return render_template( + "main/donate.html", + days_since_signup=days_since_signup, + time_entries_count=time_entries_count, + total_hours=total_hours, + ) + + +@main_bp.route("/donate/track-click", methods=["POST"]) +@login_required +def track_donation_click(): + """Track donation link clicks""" + try: + from app.models import DonationInteraction + + data = request.get_json() or {} + source = data.get("source", "unknown") + + # Get user metrics + metrics = DonationInteraction.get_user_engagement_metrics(current_user.id) + + # Record click + DonationInteraction.record_interaction( + user_id=current_user.id, + interaction_type="link_clicked", + source=source, + user_metrics=metrics, + ) + + return jsonify({"success": True}) + except Exception as e: + # Return success even if tracking fails (e.g., table doesn't exist yet) + return jsonify({"success": True, "note": "Tracking unavailable"}) + + +@main_bp.route("/donate/track-banner-dismissal", methods=["POST"]) +@login_required +def track_banner_dismissal(): + """Track banner dismissals""" + try: + from app.models import DonationInteraction + + # Get user metrics + metrics = DonationInteraction.get_user_engagement_metrics(current_user.id) + + # Record dismissal + DonationInteraction.record_interaction( + user_id=current_user.id, + interaction_type="banner_dismissed", + source="banner", + user_metrics=metrics, + ) + + return jsonify({"success": True}) + except Exception as e: + # Return success even if tracking fails (e.g., table doesn't exist yet) + return jsonify({"success": True, "note": "Tracking unavailable"}) + + @main_bp.route("/debug/i18n") @login_required def debug_i18n(): diff --git a/app/templates/base.html b/app/templates/base.html index 4ec7a97..36156c7 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -865,9 +865,9 @@
  • - + - {{ _('Buy me a coffee') }} + {{ _('Support Development') }}
  • @@ -907,9 +907,10 @@ @@ -995,20 +997,25 @@
    -

    +

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

    -

    - {{ _('Support continued development with a coffee') }} ☕ +

    + {{ _('Your support helps fund server costs and new features') }} ☕

    @@ -1788,31 +1814,115 @@ // Close on Escape document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeAllMenus(); }); - // Support Banner Logic + // Support Banner Logic with Smart Prompts function dismissSupportBanner() { const banner = document.getElementById('supportBanner'); if (banner) { banner.classList.add('opacity-0', 'invisible', 'max-h-0', 'overflow-hidden'); banner.classList.remove('opacity-100', 'visible', 'max-h-[100px]'); - // Store dismissal timestamp (show again after 7 days) + // Store dismissal timestamp (show again after 30 days) try { localStorage.setItem('supportBannerDismissed', Date.now().toString()); + // Track dismissal + trackBannerDismissal(); } catch(e) {} } } + function trackBannerDismissal() { + // Get CSRF token from meta tag + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; + + fetch('{{ url_for("main.track_banner_dismissal") }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + credentials: 'same-origin', + body: JSON.stringify({}) + }).catch(() => { + // Silently fail if tracking doesn't work + }); + } + + function trackDonationClick(source) { + // Get CSRF token from meta tag + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; + + fetch('{{ url_for("main.track_donation_click") }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + credentials: 'same-origin', + body: JSON.stringify({ + source: source + }) + }).catch(() => { + // Silently fail if tracking doesn't work + }); + } + function shouldShowSupportBanner() { try { + // Check if dismissed recently (30 days) const dismissed = localStorage.getItem('supportBannerDismissed'); - if (!dismissed) return true; - const dismissedTime = parseInt(dismissed); - const sevenDays = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds - return (Date.now() - dismissedTime) > sevenDays; + if (dismissed) { + const dismissedTime = parseInt(dismissed); + const thirtyDays = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds + if ((Date.now() - dismissedTime) < thirtyDays) { + return false; + } + } + + // Check if user clicked donation link recently (30 days) + const lastClick = localStorage.getItem('donationLinkClicked'); + if (lastClick) { + const clickTime = parseInt(lastClick); + const thirtyDays = 30 * 24 * 60 * 60 * 1000; + if ((Date.now() - clickTime) < thirtyDays) { + return false; + } + } + + return true; } catch(e) { return true; // Show by default if localStorage fails } } + function updateBannerMessage() { + // Get user stats from page if available + const bannerTitle = document.getElementById('bannerTitle'); + const bannerMessage = document.getElementById('bannerMessage'); + + if (!bannerTitle || !bannerMessage) return; + + // Try to get user stats from data attributes or API + const userStats = window.userStats || {}; + const daysSinceSignup = userStats.days_since_signup || 0; + const timeEntriesCount = userStats.time_entries_count || 0; + const totalHours = userStats.total_hours || 0; + + // Smart messaging based on milestones + if (totalHours >= 100) { + bannerTitle.textContent = '{{ _("Amazing! You\'ve tracked over 100 hours") }}'; + bannerMessage.textContent = '{{ _("Your support helps us keep TimeTracker free and improving") }} ☕'; + } else if (timeEntriesCount >= 50) { + bannerTitle.textContent = '{{ _("Great progress! You\'ve logged 50+ entries") }}'; + bannerMessage.textContent = '{{ _("Help us continue developing features you love") }} ☕'; + } else if (daysSinceSignup >= 7) { + bannerTitle.textContent = '{{ _("Thanks for using TimeTracker!") }}'; + bannerMessage.textContent = '{{ _("Your support helps fund server costs and new features") }} ☕'; + } else { + // Default message + bannerTitle.textContent = '{{ _("Enjoying TimeTracker?") }}'; + bannerMessage.textContent = '{{ _("Your support helps fund server costs and new features") }} ☕'; + } + } + // Show support banner if conditions are met // Check immediately to reserve space and prevent layout shift (function() { @@ -1820,6 +1930,9 @@ if (!banner) return; if (shouldShowSupportBanner()) { + // Update banner message based on user stats + updateBannerMessage(); + // Reserve space immediately by removing height constraints // This prevents layout shift when banner becomes visible banner.classList.remove('max-h-0', 'overflow-hidden'); @@ -1835,9 +1948,60 @@ banner.classList.add('max-h-0', 'overflow-hidden'); } })(); + + // Track donation link clicks + document.addEventListener('click', function(e) { + const link = e.target.closest('a[href*="buymeacoffee.com"]'); + if (link) { + try { + localStorage.setItem('donationLinkClicked', Date.now().toString()); + } catch(e) {} + } + }); + + + {% if current_user.is_authenticated %} + + {% endif %} + + + + + + + {% block scripts_extra %}{% endblock %} diff --git a/app/templates/main/about.html b/app/templates/main/about.html index 8bdb51a..c0a5670 100644 --- a/app/templates/main/about.html +++ b/app/templates/main/about.html @@ -175,10 +175,35 @@ {{ _('View on GitHub') }} - - {{ _('Support Development') }} + + {{ _('Support Development') }} + + +
    +
    +
    + +
    +
    +

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

    +

    + {{ _('TimeTracker is free and open-source. Your support helps fund server costs, new features, security updates, and keeps the project alive. Every contribution makes a difference!') }} +

    + +
    +
    +
    diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index d917fb7..439fad9 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -264,7 +264,7 @@ - +
    @@ -273,18 +273,36 @@ {{ _('Support TimeTracker') }}

    - {{ _('Enjoying TimeTracker? Consider buying me a coffee to support continued development!') }} + {{ _('Your support helps fund server costs, new features, and keeps TimeTracker free for everyone.') }}

    + {% if time_entries_count > 0 or total_hours > 0 %} +
    + {% if time_entries_count > 0 %} +
    {{ _('You\'ve tracked %(count)s time entries', count=time_entries_count) }}
    + {% endif %} + {% if total_hours > 0 %} +
    {{ _('You\'ve logged %(hours)s hours', hours="%.1f"|format(total_hours)) }}
    + {% endif %} +
    + {% endif %}
    - - - {{ _('Buy me a coffee') }} - - +
    diff --git a/app/templates/main/donate.html b/app/templates/main/donate.html new file mode 100644 index 0000000..39a21a3 --- /dev/null +++ b/app/templates/main/donate.html @@ -0,0 +1,239 @@ +{% extends "base.html" %} +{% block title %}{{ _('Support TimeTracker') }}{% endblock %} + +{% block content %} +
    + + + + +
    +
    + +

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

    +

    + {{ _('Your support helps keep TimeTracker free and continuously improving') }} +

    + +
    +
    + + +
    +

    + + {{ _('Why Your Support Matters') }} +

    +
    +

    + {{ _('TimeTracker is a free, open-source project built with passion and dedication. Your donations directly support:') }} +

    + +
    +
    + +
    +

    {{ _('Server Infrastructure') }}

    +

    + {{ _('Hosting, databases, and CDN costs to keep TimeTracker fast and reliable') }} +

    +
    +
    + +
    + +
    +

    {{ _('Feature Development') }}

    +

    + {{ _('New features, improvements, and bug fixes based on your feedback') }} +

    +
    +
    + +
    + +
    +

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

    +

    + {{ _('Regular security updates, dependency maintenance, and performance optimization') }} +

    +
    +
    + +
    + +
    +

    {{ _('Internationalization') }}

    +

    + {{ _('Translation support, localization, and making TimeTracker accessible worldwide') }} +

    +
    +
    +
    +
    +
    + + + {% if time_entries_count > 0 or total_hours > 0 %} +
    +

    + + {{ _('Your TimeTracker Journey') }} +

    +
    + {% if days_since_signup > 0 %} +
    +
    {{ days_since_signup }}
    +
    + {{ _('Days using TimeTracker') }} +
    +
    + {% endif %} + + {% if time_entries_count > 0 %} +
    +
    {{ time_entries_count }}
    +
    + {{ _('Time entries tracked') }} +
    +
    + {% endif %} + + {% if total_hours > 0 %} +
    +
    {{ "%.1f"|format(total_hours) }}
    +
    + {{ _('Hours tracked') }} +
    +
    + {% endif %} +
    +

    + {{ _('Thank you for being part of the TimeTracker community!') }} +

    +
    + {% endif %} + + +
    +

    + + {{ _('How to Support') }} +

    +

    + {{ _('Every contribution, no matter the size, makes a difference. Your support helps ensure TimeTracker remains free and continues to evolve.') }} +

    + + + +

    + {{ _('You\'ll be redirected to Buy Me a Coffee where you can choose your contribution amount') }} +

    +
    + + +
    +

    + + {{ _('The Impact of Your Support') }} +

    +
      +
    • + + {{ _('Enables faster development cycles and quicker feature releases') }} +
    • +
    • + + {{ _('Supports better documentation and user guides') }} +
    • +
    • + + {{ _('Helps maintain high-quality code and security standards') }} +
    • +
    • + + {{ _('Keeps TimeTracker free and accessible for everyone') }} +
    • +
    +
    + + + +
    + + +{% endblock %} + diff --git a/app/templates/main/help.html b/app/templates/main/help.html index f715d97..4ac248d 100644 --- a/app/templates/main/help.html +++ b/app/templates/main/help.html @@ -805,8 +805,8 @@ {{ _('Report Issue') }} - - {{ _('Support Development') }} + + {{ _('Support Development') }} diff --git a/migrations/versions/094_add_donation_interactions.py b/migrations/versions/094_add_donation_interactions.py new file mode 100644 index 0000000..358312d --- /dev/null +++ b/migrations/versions/094_add_donation_interactions.py @@ -0,0 +1,42 @@ +"""Add donation_interactions table + +Revision ID: 094_add_donation_interactions +Revises: 093_remove_ui_allow_flags +Create Date: 2025-01-27 12:00:00 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = "094_add_donation_interactions" +down_revision = "093_remove_ui_allow_flags" +branch_labels = None +depends_on = None + + +def upgrade(): + """Create donation_interactions table to track user interactions with donation prompts""" + op.create_table( + "donation_interactions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("interaction_type", sa.String(length=50), nullable=False), + sa.Column("source", sa.String(length=100), nullable=True), + sa.Column("time_entries_count", sa.Integer(), nullable=True), + sa.Column("days_since_signup", sa.Integer(), nullable=True), + sa.Column("total_hours", sa.Float(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_donation_interactions_user_id", "donation_interactions", ["user_id"]) + + +def downgrade(): + """Drop donation_interactions table""" + op.drop_index("idx_donation_interactions_user_id", table_name="donation_interactions") + op.drop_table("donation_interactions") + From d4ee8fe4a393f29576774c55437e98f896bcfedf Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Tue, 30 Dec 2025 09:52:31 +0100 Subject: [PATCH 2/2] version bump to 4.8.3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1221946..506e07d 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ from setuptools import setup, find_packages setup( name='timetracker', - version='4.8.2', + version='4.8.3', packages=find_packages(), include_package_data=True, install_requires=[