added habit_streaks to habits table
This commit is contained in:
@@ -19,60 +19,39 @@ export type Habit = {
|
|||||||
type HabitRow = {
|
type HabitRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
streak: number;
|
||||||
|
last_completed_on: string | null;
|
||||||
type HabitLogRow = {
|
|
||||||
habit_id: string;
|
|
||||||
completed_on: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTodayDateKey(date = new Date()) {
|
export function getTodayDateKey(date = new Date()) {
|
||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateStreak(completedDates: string[], today = getTodayDateKey()) {
|
function getDayDiff(fromDate: string, toDate: string) {
|
||||||
if (completedDates.length === 0) {
|
const from = new Date(`${fromDate}T00:00:00`);
|
||||||
return 0;
|
const to = new Date(`${toDate}T00:00:00`);
|
||||||
|
|
||||||
|
return Math.round((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
|
|
||||||
const dates = new Set(completedDates);
|
function shouldResetStreakForMissedDay(
|
||||||
let streak = 0;
|
lastCompletedOn: string | null,
|
||||||
const cursor = new Date(`${today}T00:00:00`);
|
|
||||||
|
|
||||||
if (!dates.has(today)) {
|
|
||||||
cursor.setDate(cursor.getDate() - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (dates.has(getTodayDateKey(cursor))) {
|
|
||||||
streak += 1;
|
|
||||||
cursor.setDate(cursor.getDate() - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return streak;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLastCompletedDate(completedDates: string[], today = getTodayDateKey()) {
|
|
||||||
if (completedDates.includes(today)) {
|
|
||||||
return today;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedDates = [...completedDates].sort().reverse();
|
|
||||||
return sortedDates[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapHabitRow(
|
|
||||||
row: HabitRow,
|
|
||||||
logsByHabitId: Map<string, string[]>,
|
|
||||||
today = getTodayDateKey(),
|
today = getTodayDateKey(),
|
||||||
): Habit {
|
) {
|
||||||
const completedDates = logsByHabitId.get(row.id) ?? [];
|
if (!lastCompletedOn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDayDiff(lastCompletedOn, today) > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHabitRow(row: HabitRow): Habit {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
label: row.name,
|
label: row.name,
|
||||||
catalogId: getCatalogIdByLabel(row.name),
|
catalogId: getCatalogIdByLabel(row.name),
|
||||||
streak: calculateStreak(completedDates, today),
|
streak: row.streak,
|
||||||
lastCompletedDate: getLastCompletedDate(completedDates, today),
|
lastCompletedDate: row.last_completed_on,
|
||||||
isCustom: !isCatalogLabel(row.name),
|
isCustom: !isCatalogLabel(row.name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -91,26 +70,55 @@ async function getAuthenticatedUserId() {
|
|||||||
return user.id;
|
return user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHabitLogs(userId: string) {
|
async function reconcileMissedDayStreaks(
|
||||||
|
habits: HabitRow[],
|
||||||
|
today = getTodayDateKey(),
|
||||||
|
) {
|
||||||
|
const supabase = createClient();
|
||||||
|
const habitsToReset = habits.filter(
|
||||||
|
(habit) =>
|
||||||
|
habit.streak > 0 &&
|
||||||
|
shouldResetStreakForMissedDay(habit.last_completed_on, today),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (habitsToReset.length === 0) {
|
||||||
|
return habits;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
habitsToReset.map((habit) =>
|
||||||
|
supabase.from("habits").update({ streak: 0 }).eq("id", habit.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return habits.map((habit) =>
|
||||||
|
habitsToReset.some((item) => item.id === habit.id)
|
||||||
|
? { ...habit, streak: 0 }
|
||||||
|
: habit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPreviousCompletedDate(
|
||||||
|
userId: string,
|
||||||
|
habitId: string,
|
||||||
|
today = getTodayDateKey(),
|
||||||
|
) {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("habit_logs")
|
.from("habit_logs")
|
||||||
.select("habit_id, completed_on")
|
.select("completed_on")
|
||||||
.eq("user_id", userId);
|
.eq("user_id", userId)
|
||||||
|
.eq("habit_id", habitId)
|
||||||
|
.neq("completed_on", today)
|
||||||
|
.order("completed_on", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw new Error(error.message);
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const logsByHabitId = new Map<string, string[]>();
|
return data?.completed_on ?? null;
|
||||||
|
|
||||||
for (const log of (data ?? []) as HabitLogRow[]) {
|
|
||||||
const existing = logsByHabitId.get(log.habit_id) ?? [];
|
|
||||||
existing.push(log.completed_on);
|
|
||||||
logsByHabitId.set(log.habit_id, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
return logsByHabitId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isHabitCompletedToday(habit: Habit, today = getTodayDateKey()) {
|
export function isHabitCompletedToday(habit: Habit, today = getTodayDateKey()) {
|
||||||
@@ -127,7 +135,7 @@ export async function getHabits(): Promise<Habit[]> {
|
|||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("habits")
|
.from("habits")
|
||||||
.select("id, name")
|
.select("id, name, streak, last_completed_on")
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
.eq("active", true)
|
.eq("active", true)
|
||||||
.order("created_at", { ascending: true });
|
.order("created_at", { ascending: true });
|
||||||
@@ -136,11 +144,9 @@ export async function getHabits(): Promise<Habit[]> {
|
|||||||
throw new Error(error.message);
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const logsByHabitId = await fetchHabitLogs(userId);
|
const rows = await reconcileMissedDayStreaks((data ?? []) as HabitRow[]);
|
||||||
|
|
||||||
return ((data ?? []) as HabitRow[]).map((row) =>
|
return rows.map(mapHabitRow);
|
||||||
mapHabitRow(row, logsByHabitId),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureCatalogHabit(catalogId: string): Promise<Habit | null> {
|
export async function ensureCatalogHabit(catalogId: string): Promise<Habit | null> {
|
||||||
@@ -159,7 +165,7 @@ export async function ensureCatalogHabit(catalogId: string): Promise<Habit | nul
|
|||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data: existing, error: existingError } = await supabase
|
const { data: existing, error: existingError } = await supabase
|
||||||
.from("habits")
|
.from("habits")
|
||||||
.select("id, name")
|
.select("id, name, streak, last_completed_on")
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
.eq("name", entry.label)
|
.eq("name", entry.label)
|
||||||
.eq("active", true)
|
.eq("active", true)
|
||||||
@@ -178,8 +184,9 @@ export async function ensureCatalogHabit(catalogId: string): Promise<Habit | nul
|
|||||||
user_id: userId,
|
user_id: userId,
|
||||||
name: entry.label,
|
name: entry.label,
|
||||||
active: true,
|
active: true,
|
||||||
|
streak: 0,
|
||||||
})
|
})
|
||||||
.select("id, name")
|
.select("id, name, streak, last_completed_on")
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (insertError) {
|
if (insertError) {
|
||||||
@@ -189,8 +196,8 @@ export async function ensureCatalogHabit(catalogId: string): Promise<Habit | nul
|
|||||||
row = inserted as HabitRow;
|
row = inserted as HabitRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logsByHabitId = await fetchHabitLogs(userId);
|
const [reconciledRow] = await reconcileMissedDayStreaks([row]);
|
||||||
return mapHabitRow(row, logsByHabitId);
|
return mapHabitRow(reconciledRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
|
export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
|
||||||
@@ -202,6 +209,25 @@ export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
|
|||||||
|
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const today = getTodayDateKey();
|
const today = getTodayDateKey();
|
||||||
|
|
||||||
|
const { data: habit, error: habitError } = await supabase
|
||||||
|
.from("habits")
|
||||||
|
.select("id, name, streak, last_completed_on")
|
||||||
|
.eq("id", habitId)
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (habitError) {
|
||||||
|
throw new Error(habitError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!habit) {
|
||||||
|
throw new Error("Habit not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [reconciledHabit] = await reconcileMissedDayStreaks([habit as HabitRow]);
|
||||||
|
let currentStreak = reconciledHabit.streak;
|
||||||
|
|
||||||
const { data: existingLog, error: existingLogError } = await supabase
|
const { data: existingLog, error: existingLogError } = await supabase
|
||||||
.from("habit_logs")
|
.from("habit_logs")
|
||||||
.select("id")
|
.select("id")
|
||||||
@@ -223,6 +249,22 @@ export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
|
|||||||
if (deleteError) {
|
if (deleteError) {
|
||||||
throw new Error(deleteError.message);
|
throw new Error(deleteError.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextStreak = Math.max(0, currentStreak - 1);
|
||||||
|
const previousCompletedDate = await getPreviousCompletedDate(userId, habitId);
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("habits")
|
||||||
|
.update({
|
||||||
|
streak: nextStreak,
|
||||||
|
last_completed_on: previousCompletedDate,
|
||||||
|
})
|
||||||
|
.eq("id", habitId)
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
throw new Error(updateError.message);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const { error: insertError } = await supabase.from("habit_logs").insert({
|
const { error: insertError } = await supabase.from("habit_logs").insert({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -233,6 +275,21 @@ export async function toggleHabitCompletion(habitId: string): Promise<Habit[]> {
|
|||||||
if (insertError) {
|
if (insertError) {
|
||||||
throw new Error(insertError.message);
|
throw new Error(insertError.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextStreak = currentStreak + 1;
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("habits")
|
||||||
|
.update({
|
||||||
|
streak: nextStreak,
|
||||||
|
last_completed_on: today,
|
||||||
|
})
|
||||||
|
.eq("id", habitId)
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
throw new Error(updateError.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyHabitPetDataUpdated();
|
notifyHabitPetDataUpdated();
|
||||||
@@ -257,6 +314,7 @@ export async function addHabit(label: string): Promise<Habit[]> {
|
|||||||
user_id: userId,
|
user_id: userId,
|
||||||
name: trimmedLabel,
|
name: trimmedLabel,
|
||||||
active: true,
|
active: true,
|
||||||
|
streak: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
20
supabase/migrations/20260524150000_habits_streak.sql
Normal file
20
supabase/migrations/20260524150000_habits_streak.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
alter table public.habits
|
||||||
|
add column if not exists streak int not null default 0,
|
||||||
|
add column if not exists last_completed_on date;
|
||||||
|
|
||||||
|
alter table public.habits
|
||||||
|
drop constraint if exists habits_streak_check;
|
||||||
|
|
||||||
|
alter table public.habits
|
||||||
|
add constraint habits_streak_check
|
||||||
|
check (streak >= 0);
|
||||||
|
|
||||||
|
update public.habits h
|
||||||
|
set last_completed_on = latest.completed_on
|
||||||
|
from (
|
||||||
|
select habit_id, max(completed_on) as completed_on
|
||||||
|
from public.habit_logs
|
||||||
|
group by habit_id
|
||||||
|
) as latest
|
||||||
|
where h.id = latest.habit_id
|
||||||
|
and h.last_completed_on is null;
|
||||||
Reference in New Issue
Block a user