mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
b0dde80ba9
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.
116 lines
4.4 KiB
Python
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,
|
|
}
|