Spaces:
Runtime error
Runtime error
| /** | |
| * 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); | |
| } | |