Spaces:
Runtime error
Runtime error
File size: 6,122 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | /**
* 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);
}
|