|
# retoor <retoor@molodetz.nl>
|
|
|
|
from .core import TTLCache, datetime, db, timedelta, timezone
|
|
|
|
|
|
def record_activity(user_uid: str, action: str) -> int:
|
|
if not user_uid or not action or "user_activity" not in db.tables:
|
|
return 0
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
with db:
|
|
db.query(
|
|
"INSERT INTO user_activity (user_uid, action, count, first_at, last_at) "
|
|
"VALUES (:u, :a, 1, :now, :now) "
|
|
"ON CONFLICT(user_uid, action) DO UPDATE SET "
|
|
"count = count + 1, last_at = excluded.last_at",
|
|
u=user_uid,
|
|
a=action,
|
|
now=now,
|
|
)
|
|
rows = list(
|
|
db.query(
|
|
"SELECT count FROM user_activity WHERE user_uid = :u AND action = :a",
|
|
u=user_uid,
|
|
a=action,
|
|
)
|
|
)
|
|
return int(rows[0]["count"]) if rows else 0
|
|
|
|
|
|
def record_unique_activity(user_uid: str, action: str, target: str) -> int | None:
|
|
if not user_uid or not action or "user_activity_seen" not in db.tables:
|
|
return None
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
with db:
|
|
db.query(
|
|
"INSERT OR IGNORE INTO user_activity_seen "
|
|
"(user_uid, action, target, created_at) VALUES (:u, :a, :t, :now)",
|
|
u=user_uid,
|
|
a=action,
|
|
t=str(target),
|
|
now=now,
|
|
)
|
|
changed = list(db.query("SELECT changes() AS c"))
|
|
if not changed or not changed[0]["c"]:
|
|
return None
|
|
rows = list(
|
|
db.query(
|
|
"SELECT COUNT(*) AS c FROM user_activity_seen "
|
|
"WHERE user_uid = :u AND action = :a",
|
|
u=user_uid,
|
|
a=action,
|
|
)
|
|
)
|
|
return int(rows[0]["c"]) if rows else 0
|
|
|
|
|
|
def get_user_activity(user_uid: str) -> dict:
|
|
if not user_uid or "user_activity" not in db.tables:
|
|
return {}
|
|
rows = db.query(
|
|
"SELECT action, count FROM user_activity WHERE user_uid = :u",
|
|
u=user_uid,
|
|
)
|
|
return {row["action"]: int(row["count"]) for row in rows}
|
|
|
|
|
|
_activity_cache = TTLCache(ttl=300, max_size=1000)
|
|
|
|
|
|
_ACTIVITY_TABLES = ("posts", "comments", "gists", "projects")
|
|
|
|
|
|
def get_activity_calendar(user_uid: str) -> dict:
|
|
cached = _activity_cache.get(user_uid)
|
|
if cached is not None:
|
|
return cached
|
|
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
|
|
calendar: dict[str, int] = {}
|
|
if sources:
|
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=364)).date().isoformat()
|
|
union = " UNION ALL ".join(
|
|
f"SELECT created_at FROM {table} WHERE user_uid = :u AND deleted_at IS NULL"
|
|
for table in sources
|
|
)
|
|
rows = db.query(
|
|
f"SELECT date(created_at) AS day, COUNT(*) AS c FROM ({union}) WHERE date(created_at) >= :cutoff GROUP BY day",
|
|
u=user_uid,
|
|
cutoff=cutoff,
|
|
)
|
|
for row in rows:
|
|
if row["day"]:
|
|
calendar[row["day"]] = row["c"]
|
|
_activity_cache.set(user_uid, calendar)
|
|
return calendar
|
|
|
|
|
|
def _activity_level(count: int) -> int:
|
|
if count <= 0:
|
|
return 0
|
|
if count == 1:
|
|
return 1
|
|
if count <= 3:
|
|
return 2
|
|
if count <= 6:
|
|
return 3
|
|
return 4
|
|
|
|
|
|
def get_first_activity_date(user_uid: str):
|
|
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
|
|
if not sources:
|
|
return None
|
|
union = " UNION ALL ".join(
|
|
f"SELECT MIN(created_at) AS m FROM {table} WHERE user_uid = :u AND deleted_at IS NULL"
|
|
for table in sources
|
|
)
|
|
for row in db.query(f"SELECT MIN(m) AS first FROM ({union})", u=user_uid):
|
|
if row["first"]:
|
|
return datetime.fromisoformat(row["first"]).date()
|
|
return None
|
|
|
|
|
|
HEATMAP_WEEKS = 53
|
|
|
|
|
|
def get_activity_heatmap(user_uid: str) -> list:
|
|
calendar = get_activity_calendar(user_uid)
|
|
today = datetime.now(timezone.utc).date()
|
|
week_start = today - timedelta(days=today.weekday())
|
|
start = week_start - timedelta(weeks=HEATMAP_WEEKS - 1)
|
|
first = get_first_activity_date(user_uid)
|
|
if first:
|
|
first_week = first - timedelta(days=first.weekday())
|
|
if first_week > start:
|
|
start = first_week
|
|
weeks = []
|
|
for w in range(HEATMAP_WEEKS):
|
|
week = []
|
|
for d in range(7):
|
|
day = start + timedelta(days=w * 7 + d)
|
|
iso = day.isoformat()
|
|
count = calendar.get(iso, 0)
|
|
week.append({"date": iso, "count": count, "level": _activity_level(count)})
|
|
weeks.append(week)
|
|
return weeks
|
|
|
|
|
|
def get_activity_months(weeks: list) -> list:
|
|
if not weeks:
|
|
return []
|
|
last = len(weeks) - 1
|
|
labels = []
|
|
for i in range(6):
|
|
column = round(i * last / 5)
|
|
iso = weeks[column][0]["date"]
|
|
labels.append(datetime.fromisoformat(iso).strftime("%b"))
|
|
return labels
|
|
|
|
|
|
def get_streaks(user_uid: str) -> dict:
|
|
calendar = get_activity_calendar(user_uid)
|
|
if not calendar:
|
|
return {"current": 0, "longest": 0}
|
|
dates = sorted(datetime.fromisoformat(day).date() for day in calendar)
|
|
date_set = set(dates)
|
|
longest = 1
|
|
run = 1
|
|
for index in range(1, len(dates)):
|
|
if (dates[index] - dates[index - 1]).days == 1:
|
|
run += 1
|
|
else:
|
|
run = 1
|
|
longest = max(longest, run)
|
|
today = datetime.now(timezone.utc).date()
|
|
cursor = today
|
|
if today not in date_set and (today - timedelta(days=1)) in date_set:
|
|
cursor = today - timedelta(days=1)
|
|
current = 0
|
|
while cursor in date_set:
|
|
current += 1
|
|
cursor = cursor - timedelta(days=1)
|
|
return {"current": current, "longest": longest}
|