Files
OmniRoute/src/lib/gamification/streaks.ts
Paijo 81fb3f50e8 feat: gamification & leaderboard system (#2405)
Integrated into release/v3.8.0
2026-05-19 09:46:20 -03:00

195 lines
6.0 KiB
TypeScript

/**
* Streak Tracker for OmniRoute Gamification
*
* Tracks consecutive daily active usage per API key.
* Stores streak data in the existing `key_value` table with
* namespace `gamification:streaks` to avoid schema changes.
*
* @module lib/gamification/streaks
*/
import { getDbInstance, isBuildPhase, isCloud } from "../db/core";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface StreakData {
/** Current consecutive active days */
currentStreak: number;
/** Longest ever consecutive streak */
longestStreak: number;
/** Last day the user was active (YYYY-MM-DD) */
lastActiveDate: string;
/** Date the current streak started (YYYY-MM-DD) */
streakStartDate: string;
}
interface StatementLike<TRow = unknown> {
get: (...params: unknown[]) => TRow | undefined;
run: (...params: unknown[]) => { changes?: number };
all: (...params: unknown[]) => TRow[];
}
interface DbLike {
prepare: <TRow = unknown>(sql: string) => StatementLike<TRow>;
}
interface KeyValueRow {
value: string;
}
// ─── Constants ───────────────────────────────────────────────────────────────
const NAMESPACE = "gamification:streaks";
/** One day in milliseconds */
const MS_PER_DAY = 86_400_000;
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Get today's date as YYYY-MM-DD in UTC.
*/
function todayUtc(): string {
return new Date().toISOString().split("T")[0];
}
/**
* Get yesterday's date as YYYY-MM-DD in UTC.
*/
function yesterdayUtc(): string {
return new Date(Date.now() - MS_PER_DAY).toISOString().split("T")[0];
}
function emptyStreak(): StreakData {
return {
currentStreak: 0,
longestStreak: 0,
lastActiveDate: "",
streakStartDate: "",
};
}
function parseStreakJson(raw: string): StreakData {
try {
const parsed = JSON.parse(raw) as Partial<StreakData>;
return {
currentStreak: typeof parsed.currentStreak === "number" ? parsed.currentStreak : 0,
longestStreak: typeof parsed.longestStreak === "number" ? parsed.longestStreak : 0,
lastActiveDate: typeof parsed.lastActiveDate === "string" ? parsed.lastActiveDate : "",
streakStartDate: typeof parsed.streakStartDate === "string" ? parsed.streakStartDate : "",
};
} catch {
return emptyStreak();
}
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Get the current streak data for an API key.
*
* @param apiKeyId - The API key identifier
* @returns StreakData with current/longest streak and date info
*
* @example
* const streak = await getStreak("key_abc123");
* console.log(streak.currentStreak); // 7
*/
export async function getStreak(apiKeyId: string): Promise<StreakData> {
if (isBuildPhase || isCloud) return emptyStreak();
const db = getDbInstance() as unknown as DbLike;
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get(NAMESPACE, apiKeyId) as KeyValueRow | undefined;
if (!row?.value) return emptyStreak();
return parseStreakJson(row.value);
}
/**
* Update streak for today. Returns the new current streak count.
*
* Behavior:
* - If already active today, returns current streak (no-op).
* - If active yesterday, increments streak.
* - Otherwise, resets streak to 1 (new streak).
*
* Also updates longestStreak if the new streak is a personal record.
*
* @param apiKeyId - The API key identifier
* @returns New current streak count
*
* @example
* const count = await updateStreak("key_abc123");
* console.log(count); // 8
*/
export async function updateStreak(apiKeyId: string): Promise<number> {
if (isBuildPhase || isCloud) return 0;
const db = getDbInstance() as unknown as DbLike;
const today = todayUtc();
const streak = await getStreak(apiKeyId);
// Already counted today
if (streak.lastActiveDate === today) {
return streak.currentStreak;
}
const yesterday = yesterdayUtc();
let newStreak: number;
if (streak.lastActiveDate === yesterday) {
// Consecutive day — extend streak
newStreak = streak.currentStreak + 1;
} else {
// Streak broken or first activity — start fresh
newStreak = 1;
}
const newData: StreakData = {
currentStreak: newStreak,
longestStreak: Math.max(newStreak, streak.longestStreak),
lastActiveDate: today,
streakStartDate: newStreak === 1 ? today : streak.streakStartDate,
};
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
NAMESPACE,
apiKeyId,
JSON.stringify(newData)
);
return newStreak;
}
/**
* Check if a streak is still active (last activity within the last 2 days).
* Does not modify any data.
*
* @param apiKeyId - The API key identifier
* @returns true if the streak is still alive
*/
export async function isStreakActive(apiKeyId: string): Promise<boolean> {
const streak = await getStreak(apiKeyId);
if (!streak.lastActiveDate) return false;
const last = new Date(streak.lastActiveDate + "T00:00:00Z").getTime();
const now = Date.now();
const daysSince = Math.floor((now - last) / MS_PER_DAY);
return daysSince <= 1; // Today or yesterday
}
/**
* Reset streak data for an API key (admin/testing use).
*
* @param apiKeyId - The API key identifier
*/
export async function resetStreak(apiKeyId: string): Promise<void> {
if (isBuildPhase || isCloud) return;
const db = getDbInstance() as unknown as DbLike;
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(NAMESPACE, apiKeyId);
}