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);
}