Files
TimeTracker/app/models/donation_interaction.py
T
Dries Peeters b0dde80ba9 feat(web): high-visibility support modal, prompts, and supporter UX
Add a support modal with usage stats, tier and license links, share control, and offline-safe outbound CTAs. Surface support from the header, sidebar, user menu, dashboard card, and settings "Support & Community" section without hiding entry points when a supporter license is active.

Introduce UsageStatsService and a persisted users.support_stats_reports_generated counter incremented on key report exports and custom report views. Add SupportPromptService for session-scoped soft toasts (after export, dashboard milestones, long session via POST /donate/request-soft-prompt).

Wire consent-aware track_event names support.* and mirror funnel rows in DonationInteraction; fix has_recent_donation_click to treat link_clicked as a recent click. Document events and SUPPORT_* / migration notes in docs.

Tests: tests/test_support_services.py for prompt and usage stats behavior.
2026-04-15 10:55:37 +02:00

116 lines
4.4 KiB
Python

"""Model to track donation banner interactions and user engagement metrics.
Canonical interaction_type values (funnel):
- page_viewed: user viewed the support/donate page
- 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
- dashboard_widget, donate_page_hero, donate_page_button, donate_page_key
- about_page, help_page
Sources ending with _key are key-purchase clicks; others are donate/learn-more.
"""
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: page_viewed | banner_impression | banner_dismissed | link_clicked
interaction_type = db.Column(db.String(50), nullable=False)
# Placement/source: header | banner | banner_bmc | banner_paypal | banner_key | dashboard_widget | donate_page_* | about_page | help_page
source = db.Column(db.String(100), nullable=True)
# A/B test variant for experiments (e.g. control | key_first | cta_alt)
variant = db.Column(db.String(50), nullable=True)
# 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"<DonationInteraction {self.interaction_type} by user {self.user_id}>"
@staticmethod
def record_interaction(
user_id: int,
interaction_type: str,
source: str = None,
user_metrics: dict = None,
variant: str = None,
):
"""Record a donation interaction (optionally with A/B variant)."""
interaction = DonationInteraction(
user_id=user_id,
interaction_type=interaction_type,
source=source,
variant=variant,
)
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 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(
DonationInteraction.user_id == user_id,
DonationInteraction.interaction_type.in_(interaction_types),
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,
}