mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
195 lines
6.0 KiB
TypeScript
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);
|
|
}
|