Files
maziggy 31e61cebd7 feat(sponsor-prompt): lower print/archive/cost thresholds to fire for typical new installs
The toast was calibrated for power users — lowest bars were 100 prints,
50 archives, 100 cost. Most installs never crossed any of them, especially
with the install base ~doubling since March. Matomo confirms: only 4
prints-100 and 3 archives-50 deeplink visits to /sponsors.html in a 7-day
window despite tens of thousands of weekly pulls.

Adds lower thresholds without changing priority order or cooldown:
  PRINT_MILESTONES   = (10, 25, ...)
  ARCHIVE_MILESTONES = (5, 10, ...)
  COST_MILESTONES    = (25, 50, ...)

Existing toast copy uses {count}/{total} interpolation in all 11 locales,
so no i18n changes. Tests rebalanced so "below the floor" still tests
with the new floor; new test_fires_at_lowest_threshold pins prints-10.
2026-06-26 16:48:22 +02:00

257 lines
8.9 KiB
Python

"""Sponsor-prompt trigger evaluator and dismiss handler.
Drives the in-app "support keeps Bambuddy independent" toast. Trigger families
fire at milestones the user has earned (prints, archives, filament cost,
anniversary) plus a soft version-update nudge after a major upgrade.
A 14-day cooldown applies across ALL families: if any toast fired in the last
14 days, no new toast fires. Each individual milestone is shown at most once
per user (or once per install in auth-disabled mode); version-update is the
exception — it re-arms every time the running version is newer than the one
last acknowledged.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import APP_VERSION
from backend.app.models.archive import PrintArchive
from backend.app.models.print_log import PrintLogEntry
from backend.app.models.sponsor_toast_state import SponsorToastState
from backend.app.models.user import User
logger = logging.getLogger(__name__)
COOLDOWN_DAYS = 14
PRINT_MILESTONES = (10, 25, 100, 500, 1000, 2500, 5000)
COST_MILESTONES = (25, 50, 100, 500, 1000)
ARCHIVE_MILESTONES = (5, 10, 50, 250, 1000)
ANNIVERSARY_YEARS = 1
@dataclass
class Trigger:
"""Evaluated trigger result returned to the frontend."""
milestone: str # e.g. "prints-500", "anniversary-1", "version-update"
family: str # "prints" | "cost" | "archives" | "anniversary" | "version-update"
threshold: int | None = None
payload: dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# State helpers
# ---------------------------------------------------------------------------
async def _get_or_create_state(db: AsyncSession, user_id: int | None) -> SponsorToastState:
"""Fetch the state row for this user (or the install-default NULL row).
Creates the row lazily on first access so the migration doesn't need to
seed anything.
"""
if user_id is None:
stmt = select(SponsorToastState).where(SponsorToastState.user_id.is_(None))
else:
stmt = select(SponsorToastState).where(SponsorToastState.user_id == user_id)
result = await db.execute(stmt)
state = result.scalar_one_or_none()
if state is None:
state = SponsorToastState(user_id=user_id, milestones_seen="[]")
db.add(state)
await db.flush()
return state
def _within_cooldown(state: SponsorToastState) -> bool:
if state.last_shown_at is None:
return False
cutoff = datetime.now(timezone.utc) - timedelta(days=COOLDOWN_DAYS)
last = state.last_shown_at
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
return last >= cutoff
def _seen_milestones(state: SponsorToastState) -> set[str]:
try:
raw = json.loads(state.milestones_seen or "[]")
return set(raw) if isinstance(raw, list) else set()
except (json.JSONDecodeError, TypeError):
logger.warning(
"sponsor_toast_state.milestones_seen for user=%s was not valid JSON; resetting",
state.user_id,
)
return set()
# ---------------------------------------------------------------------------
# Per-family checks
# ---------------------------------------------------------------------------
def _user_filter(column, user_id: int | None):
return column.is_(None) if user_id is None else column == user_id
async def _check_anniversary(
db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
) -> Trigger | None:
milestone = f"anniversary-{ANNIVERSARY_YEARS}"
if milestone in seen:
return None
if user_id is None:
# Install-anchor = earliest users.created_at (the first admin row).
result = await db.execute(select(func.min(User.created_at)))
anchor = result.scalar()
else:
result = await db.execute(select(User.created_at).where(User.id == user_id))
anchor = result.scalar()
if anchor is None:
return None
if anchor.tzinfo is None:
anchor = anchor.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - anchor < timedelta(days=365 * ANNIVERSARY_YEARS):
return None
return Trigger(milestone=milestone, family="anniversary")
async def _check_prints(
db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
) -> Trigger | None:
stmt = (
select(func.count())
.select_from(PrintLogEntry)
.where(
PrintLogEntry.status == "completed",
_user_filter(PrintLogEntry.created_by_id, user_id),
)
)
completed = (await db.execute(stmt)).scalar() or 0
# Pick the LARGEST milestone the user has crossed but not yet seen.
for threshold in sorted(PRINT_MILESTONES, reverse=True):
key = f"prints-{threshold}"
if completed >= threshold and key not in seen:
return Trigger(
milestone=key,
family="prints",
threshold=threshold,
payload={"count": completed},
)
return None
async def _check_archives(
db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
) -> Trigger | None:
stmt = select(func.count()).select_from(PrintArchive).where(_user_filter(PrintArchive.created_by_id, user_id))
archived = (await db.execute(stmt)).scalar() or 0
for threshold in sorted(ARCHIVE_MILESTONES, reverse=True):
key = f"archives-{threshold}"
if archived >= threshold and key not in seen:
return Trigger(
milestone=key,
family="archives",
threshold=threshold,
payload={"count": archived},
)
return None
async def _check_cost(
db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
) -> Trigger | None:
stmt = (
select(func.coalesce(func.sum(PrintLogEntry.cost), 0) + func.coalesce(func.sum(PrintLogEntry.energy_cost), 0))
.select_from(PrintLogEntry)
.where(_user_filter(PrintLogEntry.created_by_id, user_id))
)
total = float((await db.execute(stmt)).scalar() or 0)
for threshold in sorted(COST_MILESTONES, reverse=True):
key = f"cost-{threshold}"
if total >= threshold and key not in seen:
return Trigger(
milestone=key,
family="cost",
threshold=threshold,
payload={"total": round(total, 2)},
)
return None
async def _check_version_update(
_db: AsyncSession, _user_id: int | None, _seen: set[str], state: SponsorToastState
) -> Trigger | None:
# version-update is NOT in milestones_seen — it has its own state column
# so it can re-fire on each major bump.
if not APP_VERSION:
return None
last = state.last_seen_version
if last is None:
# First-ever read; treat as already-acknowledged so we don't toast
# immediately on a brand-new install. Persist current version silently.
state.last_seen_version = APP_VERSION
return None
if last == APP_VERSION:
return None
return Trigger(
milestone="version-update",
family="version-update",
payload={"from": last, "to": APP_VERSION},
)
# Priority order: most emotional / earned first; version-update is the soft fallback.
_CHECKS = (
_check_anniversary,
_check_prints,
_check_archives,
_check_cost,
_check_version_update,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def evaluate(db: AsyncSession, user_id: int | None) -> Trigger | None:
"""Return the next eligible sponsor-toast trigger, or None."""
state = await _get_or_create_state(db, user_id)
if _within_cooldown(state):
return None
seen = _seen_milestones(state)
for check in _CHECKS:
trigger = await check(db, user_id, seen, state)
if trigger is not None:
return trigger
# No triggers eligible — still commit any in-progress state changes
# (e.g. version-update's first-touch persistence).
await db.flush()
return None
async def dismiss(db: AsyncSession, user_id: int | None, milestone: str) -> None:
"""Mark a milestone as shown (sets cooldown anchor + records seen)."""
state = await _get_or_create_state(db, user_id)
if milestone == "version-update":
# Re-armable: just update last_seen_version, don't add to seen-list.
state.last_seen_version = APP_VERSION
else:
seen = _seen_milestones(state)
if milestone not in seen:
seen.add(milestone)
state.milestones_seen = json.dumps(sorted(seen))
state.last_shown_at = datetime.now(timezone.utc)
await db.flush()