duoqi-api/src/services/progress/streak-service.ts

118 lines
3.0 KiB
TypeScript

import { db } from '../../db/client.js';
import { users } from '../../db/schema.js';
import { eq, sql } from 'drizzle-orm';
export interface StreakInfo {
days: number;
lastDate: string | null;
frozen: boolean;
}
/**
* Normalize a date value (Date or string from mysql2) to 'YYYY-MM-DD' string.
*/
function toDateString(value: Date | string | null): string | null {
if (!value) return null;
if (typeof value === 'string') return value.slice(0, 10);
return value.toISOString().slice(0, 10);
}
/**
* Get the user's current streak info.
* All date comparisons use UTC date strings (YYYY-MM-DD).
*/
export async function calculateStreak(userId: string): Promise<StreakInfo> {
const [user] = await db
.select({
streakDays: users.streakDays,
streakLastDate: users.streakLastDate,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user || !user.streakLastDate) {
return { days: 0, lastDate: null, frozen: false };
}
const today = todayUtc();
const yesterday = yesterdayUtc();
const lastDate = toDateString(user.streakLastDate);
if (lastDate === today) {
return { days: user.streakDays ?? 0, lastDate, frozen: false };
}
if (lastDate === yesterday) {
return { days: user.streakDays ?? 0, lastDate, frozen: false };
}
// Streak is broken
return { days: 0, lastDate, frozen: false };
}
export async function updateStreakForCompletedChallenge(userId: string): Promise<StreakInfo> {
const today = todayUtc();
const [user] = await db
.select({
streakDays: users.streakDays,
streakLastDate: users.streakLastDate,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) {
return { days: 0, lastDate: null, frozen: false };
}
const lastDate = toDateString(user.streakLastDate);
// Already updated streak today
if (lastDate === today) {
return { days: user.streakDays ?? 0, lastDate: today, frozen: false };
}
const yesterday = yesterdayUtc();
const isConsecutive = lastDate === yesterday;
const newDays = isConsecutive ? (user.streakDays ?? 0) + 1 : 1;
await db
.update(users)
.set({ streakDays: newDays, streakLastDate: sql`CAST(${today} AS DATE)` })
.where(eq(users.id, userId));
return { days: newDays, lastDate: today, frozen: false };
}
/**
* Freeze the streak (set last date to today without incrementing).
*/
export async function freezeStreak(userId: string): Promise<StreakInfo> {
const today = todayUtc();
await db
.update(users)
.set({ streakLastDate: sql`CAST(${today} AS DATE)` })
.where(eq(users.id, userId));
const [user] = await db
.select({ streakDays: users.streakDays })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return { days: user?.streakDays ?? 0, lastDate: today, frozen: true };
}
function todayUtc(): string {
return new Date().toISOString().slice(0, 10);
}
function yesterdayUtc(): string {
const d = new Date();
d.setDate(d.getDate() - 1);
return d.toISOString().slice(0, 10);
}